Changeset 3732427
- Timestamp:
- 10/07/2026 11:09:45 AM (3 weeks ago)
- Location:
- woocommerce-services
- Files:
-
- 24 added
- 18 deleted
- 102 edited
- 1 copied
-
tags/3.7.1 (copied) (copied from woocommerce-services/trunk )
-
tags/3.7.1/classes/class-wc-connect-api-client-live.php (modified) (2 diffs)
-
tags/3.7.1/classes/class-wc-connect-api-client.php (modified) (4 diffs)
-
tags/3.7.1/classes/class-wc-connect-custom-surcharge.php (modified) (1 diff)
-
tags/3.7.1/classes/class-wc-connect-debug-tools.php (modified) (4 diffs)
-
tags/3.7.1/classes/class-wc-connect-error-notice.php (modified) (2 diffs)
-
tags/3.7.1/classes/class-wc-connect-extension-compatibility.php (modified) (1 diff)
-
tags/3.7.1/classes/class-wc-connect-functions.php (modified) (5 diffs)
-
tags/3.7.1/classes/class-wc-connect-help-view.php (modified) (5 diffs)
-
tags/3.7.1/classes/class-wc-connect-jetpack.php (modified) (1 diff)
-
tags/3.7.1/classes/class-wc-connect-label-reports.php (modified) (5 diffs)
-
tags/3.7.1/classes/class-wc-connect-logger.php (modified) (3 diffs)
-
tags/3.7.1/classes/class-wc-connect-migration-survey.php (modified) (3 diffs)
-
tags/3.7.1/classes/class-wc-connect-nux.php (modified) (14 diffs)
-
tags/3.7.1/classes/class-wc-connect-options.php (modified) (3 diffs)
-
tags/3.7.1/classes/class-wc-connect-order-presenter.php (modified) (1 diff)
-
tags/3.7.1/classes/class-wc-connect-payment-gateway.php (modified) (2 diffs)
-
tags/3.7.1/classes/class-wc-connect-payment-methods-store.php (modified) (2 diffs)
-
tags/3.7.1/classes/class-wc-connect-paypal-ec.php (modified) (7 diffs)
-
tags/3.7.1/classes/class-wc-connect-privacy.php (modified) (1 diff)
-
tags/3.7.1/classes/class-wc-connect-service-schemas-store.php (modified) (2 diffs)
-
tags/3.7.1/classes/class-wc-connect-service-schemas-validator.php (modified) (3 diffs)
-
tags/3.7.1/classes/class-wc-connect-service-settings-store.php (modified) (8 diffs)
-
tags/3.7.1/classes/class-wc-connect-settings-pages.php (modified) (2 diffs)
-
tags/3.7.1/classes/class-wc-connect-shipping-label.php (modified) (1 diff)
-
tags/3.7.1/classes/class-wc-connect-shipping-method.php (modified) (1 diff)
-
tags/3.7.1/classes/class-wc-connect-taxjar-integration.php (modified) (42 diffs)
-
tags/3.7.1/classes/class-wc-connect-tracks.php (modified) (2 diffs)
-
tags/3.7.1/classes/class-wc-rest-connect-account-settings-controller.php (modified) (1 diff)
-
tags/3.7.1/classes/class-wc-rest-connect-base-controller.php (modified) (1 diff)
-
tags/3.7.1/classes/class-wc-rest-connect-packages-controller.php (modified) (5 diffs)
-
tags/3.7.1/classes/class-wc-rest-connect-services-controller.php (modified) (2 diffs)
-
tags/3.7.1/classes/class-wc-rest-connect-shipping-label-controller.php (modified) (3 diffs)
-
tags/3.7.1/classes/class-wc-rest-connect-shipping-label-refund-controller.php (modified) (2 diffs)
-
tags/3.7.1/classes/class-wc-rest-connect-shipping-label-status-controller.php (modified) (2 diffs)
-
tags/3.7.1/classes/class-wc-rest-connect-shipping-rates-controller.php (modified) (3 diffs)
-
tags/3.7.1/classes/class-wc-rest-connect-tos-controller.php (modified) (1 diff)
-
tags/3.7.1/classes/wc-api-dev/class-wc-rest-dev-data-continents-controller.php (modified) (17 diffs)
-
tags/3.7.1/classes/wc-api-dev/class-wc-rest-dev-data-controller.php (modified) (5 diffs)
-
tags/3.7.1/composer.json (added)
-
tags/3.7.1/dist/woocommerce-services-3.7.0.css (deleted)
-
tags/3.7.1/dist/woocommerce-services-3.7.0.js (deleted)
-
tags/3.7.1/dist/woocommerce-services-3.7.1.css (added)
-
tags/3.7.1/dist/woocommerce-services-3.7.1.js (added)
-
tags/3.7.1/dist/woocommerce-services-admin-pointers-3.7.0.js (deleted)
-
tags/3.7.1/dist/woocommerce-services-admin-pointers-3.7.1.js (added)
-
tags/3.7.1/dist/woocommerce-services-banner-3.7.0.css (deleted)
-
tags/3.7.1/dist/woocommerce-services-banner-3.7.0.js (deleted)
-
tags/3.7.1/dist/woocommerce-services-banner-3.7.1.css (added)
-
tags/3.7.1/dist/woocommerce-services-banner-3.7.1.js (added)
-
tags/3.7.1/dist/woocommerce-services-new-order-taxjar-3.7.0.js (deleted)
-
tags/3.7.1/dist/woocommerce-services-new-order-taxjar-3.7.1.js (added)
-
tags/3.7.1/dist/woocommerce-services-store-notices-3.7.0.js (deleted)
-
tags/3.7.1/dist/woocommerce-services-store-notices-3.7.1.js (added)
-
tags/3.7.1/dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.css (deleted)
-
tags/3.7.1/dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js (deleted)
-
tags/3.7.1/dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.css (added)
-
tags/3.7.1/dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js (added)
-
tags/3.7.1/i18n/languages/woocommerce-services.pot (modified) (18 diffs)
-
tags/3.7.1/readme.txt (modified) (2 diffs)
-
tags/3.7.1/src/Integrations/WooCommerceBlocksIntegration.php (modified) (1 diff)
-
tags/3.7.1/src/StoreApi/StoreApiExtensionController.php (modified) (3 diffs)
-
tags/3.7.1/src/StoreNotices/StoreNoticesController.php (modified) (3 diffs)
-
tags/3.7.1/src/StoreNotices/StoreNoticesNotifier.php (modified) (1 diff)
-
tags/3.7.1/src/Tax/Address.php (modified) (2 diffs)
-
tags/3.7.1/src/Tax/StoreAddressNotice.php (added)
-
tags/3.7.1/src/Tax/StoreAddressVerifier.php (added)
-
tags/3.7.1/src/Utils.php (modified) (1 diff)
-
tags/3.7.1/vendor/composer/autoload_classmap.php (modified) (1 diff)
-
tags/3.7.1/vendor/composer/autoload_static.php (modified) (1 diff)
-
tags/3.7.1/vendor/composer/installed.php (modified) (2 diffs)
-
tags/3.7.1/vendor/composer/jetpack_autoload_classmap.php (modified) (1 diff)
-
tags/3.7.1/woocommerce-services.php (modified) (12 diffs)
-
trunk/classes/class-wc-connect-api-client-live.php (modified) (2 diffs)
-
trunk/classes/class-wc-connect-api-client.php (modified) (4 diffs)
-
trunk/classes/class-wc-connect-custom-surcharge.php (modified) (1 diff)
-
trunk/classes/class-wc-connect-debug-tools.php (modified) (4 diffs)
-
trunk/classes/class-wc-connect-error-notice.php (modified) (2 diffs)
-
trunk/classes/class-wc-connect-extension-compatibility.php (modified) (1 diff)
-
trunk/classes/class-wc-connect-functions.php (modified) (5 diffs)
-
trunk/classes/class-wc-connect-help-view.php (modified) (5 diffs)
-
trunk/classes/class-wc-connect-jetpack.php (modified) (1 diff)
-
trunk/classes/class-wc-connect-label-reports.php (modified) (5 diffs)
-
trunk/classes/class-wc-connect-logger.php (modified) (3 diffs)
-
trunk/classes/class-wc-connect-migration-survey.php (modified) (3 diffs)
-
trunk/classes/class-wc-connect-nux.php (modified) (14 diffs)
-
trunk/classes/class-wc-connect-options.php (modified) (3 diffs)
-
trunk/classes/class-wc-connect-order-presenter.php (modified) (1 diff)
-
trunk/classes/class-wc-connect-payment-gateway.php (modified) (2 diffs)
-
trunk/classes/class-wc-connect-payment-methods-store.php (modified) (2 diffs)
-
trunk/classes/class-wc-connect-paypal-ec.php (modified) (7 diffs)
-
trunk/classes/class-wc-connect-privacy.php (modified) (1 diff)
-
trunk/classes/class-wc-connect-service-schemas-store.php (modified) (2 diffs)
-
trunk/classes/class-wc-connect-service-schemas-validator.php (modified) (3 diffs)
-
trunk/classes/class-wc-connect-service-settings-store.php (modified) (8 diffs)
-
trunk/classes/class-wc-connect-settings-pages.php (modified) (2 diffs)
-
trunk/classes/class-wc-connect-shipping-label.php (modified) (1 diff)
-
trunk/classes/class-wc-connect-shipping-method.php (modified) (1 diff)
-
trunk/classes/class-wc-connect-taxjar-integration.php (modified) (42 diffs)
-
trunk/classes/class-wc-connect-tracks.php (modified) (2 diffs)
-
trunk/classes/class-wc-rest-connect-account-settings-controller.php (modified) (1 diff)
-
trunk/classes/class-wc-rest-connect-base-controller.php (modified) (1 diff)
-
trunk/classes/class-wc-rest-connect-packages-controller.php (modified) (5 diffs)
-
trunk/classes/class-wc-rest-connect-services-controller.php (modified) (2 diffs)
-
trunk/classes/class-wc-rest-connect-shipping-label-controller.php (modified) (3 diffs)
-
trunk/classes/class-wc-rest-connect-shipping-label-refund-controller.php (modified) (2 diffs)
-
trunk/classes/class-wc-rest-connect-shipping-label-status-controller.php (modified) (2 diffs)
-
trunk/classes/class-wc-rest-connect-shipping-rates-controller.php (modified) (3 diffs)
-
trunk/classes/class-wc-rest-connect-tos-controller.php (modified) (1 diff)
-
trunk/classes/wc-api-dev/class-wc-rest-dev-data-continents-controller.php (modified) (17 diffs)
-
trunk/classes/wc-api-dev/class-wc-rest-dev-data-controller.php (modified) (5 diffs)
-
trunk/composer.json (added)
-
trunk/dist/woocommerce-services-3.7.0.css (deleted)
-
trunk/dist/woocommerce-services-3.7.0.js (deleted)
-
trunk/dist/woocommerce-services-3.7.1.css (added)
-
trunk/dist/woocommerce-services-3.7.1.js (added)
-
trunk/dist/woocommerce-services-admin-pointers-3.7.0.js (deleted)
-
trunk/dist/woocommerce-services-admin-pointers-3.7.1.js (added)
-
trunk/dist/woocommerce-services-banner-3.7.0.css (deleted)
-
trunk/dist/woocommerce-services-banner-3.7.0.js (deleted)
-
trunk/dist/woocommerce-services-banner-3.7.1.css (added)
-
trunk/dist/woocommerce-services-banner-3.7.1.js (added)
-
trunk/dist/woocommerce-services-new-order-taxjar-3.7.0.js (deleted)
-
trunk/dist/woocommerce-services-new-order-taxjar-3.7.1.js (added)
-
trunk/dist/woocommerce-services-store-notices-3.7.0.js (deleted)
-
trunk/dist/woocommerce-services-store-notices-3.7.1.js (added)
-
trunk/dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.css (deleted)
-
trunk/dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js (deleted)
-
trunk/dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.css (added)
-
trunk/dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js (added)
-
trunk/i18n/languages/woocommerce-services.pot (modified) (18 diffs)
-
trunk/readme.txt (modified) (2 diffs)
-
trunk/src/Integrations/WooCommerceBlocksIntegration.php (modified) (1 diff)
-
trunk/src/StoreApi/StoreApiExtensionController.php (modified) (3 diffs)
-
trunk/src/StoreNotices/StoreNoticesController.php (modified) (3 diffs)
-
trunk/src/StoreNotices/StoreNoticesNotifier.php (modified) (1 diff)
-
trunk/src/Tax/Address.php (modified) (2 diffs)
-
trunk/src/Tax/StoreAddressNotice.php (added)
-
trunk/src/Tax/StoreAddressVerifier.php (added)
-
trunk/src/Utils.php (modified) (1 diff)
-
trunk/vendor/composer/autoload_classmap.php (modified) (1 diff)
-
trunk/vendor/composer/autoload_static.php (modified) (1 diff)
-
trunk/vendor/composer/installed.php (modified) (2 diffs)
-
trunk/vendor/composer/jetpack_autoload_classmap.php (modified) (1 diff)
-
trunk/woocommerce-services.php (modified) (12 diffs)
Legend:
- Unmodified
- Added
- Removed
-
woocommerce-services/tags/3.7.1/classes/class-wc-connect-api-client-live.php
r3598126 r3732427 71 71 'wcc_server_error', 72 72 sprintf( 73 /* translators: %d: HTTP response code */ 73 74 __( 'Error: The WooCommerce Tax server returned HTTP code: %d', 'woocommerce-services' ), 74 75 $response_code … … 92 93 'wcc_server_empty_response', 93 94 sprintf( 95 /* translators: %d: HTTP response code */ 94 96 __( 'Error: The WooCommerce Tax server returned ( %d ) and an empty response body.', 'woocommerce-services' ), 95 97 $response_code -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-api-client.php
r3452871 r3732427 97 97 'product_missing_weight', 98 98 sprintf( 99 /* translators: %d: product ID */ 99 100 __( 'Product ( ID: %d ) did not include a weight. Shipping rates cannot be calculated.', 'woocommerce-services' ), 100 101 $product->get_id() … … 112 113 'product_missing_dimension', 113 114 sprintf( 115 /* translators: %d: product ID */ 114 116 __( 'Product ( ID: %d ) is missing a dimension value. Shipping rates cannot be calculated.', 'woocommerce-services' ), 115 117 $product->get_id() … … 616 618 617 619 $data = array( 618 'host' => parse_url( $request_url, PHP_URL_HOST ), // host URL.619 'request_uri' => parse_url( $request_url, PHP_URL_PATH ), // endpoint URL.620 'host' => wp_parse_url( $request_url, PHP_URL_HOST ), // host URL. 621 'request_uri' => wp_parse_url( $request_url, PHP_URL_PATH ), // endpoint URL. 620 622 'method' => $method, 621 623 ); … … 670 672 sprintf( 671 673 '%04X%04X-%04X-%04X-%04X-%04X%04X%04X', 672 mt_rand( 0, 65535 ),673 mt_rand( 0, 65535 ),674 mt_rand( 0, 65535 ),675 mt_rand( 16384, 20479 ),676 mt_rand( 32768, 49151 ),677 mt_rand( 0, 65535 ),678 mt_rand( 0, 65535 ),679 mt_rand( 0, 65535 )674 wp_rand( 0, 65535 ), 675 wp_rand( 0, 65535 ), 676 wp_rand( 0, 65535 ), 677 wp_rand( 16384, 20479 ), 678 wp_rand( 32768, 49151 ), 679 wp_rand( 0, 65535 ), 680 wp_rand( 0, 65535 ), 681 wp_rand( 0, 65535 ) 680 682 ) 681 683 ); -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-custom-surcharge.php
r3417327 r3732427 112 112 array( 113 113 'value' => 0.28, 114 'text' => __( 'Retail Delivery Fee', 'woocommerce _services' ),114 'text' => __( 'Retail Delivery Fee', 'woocommerce-services' ), 115 115 ), 116 116 $cart -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-debug-tools.php
r3289651 r3732427 90 90 global $wpdb; 91 91 92 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- WooCommerce's tax rate table has no core reader that can filter on tax_rate_name; caching is pointless because the matched rows are deleted immediately below and the tool only runs on an explicit click in WooCommerce » Status » Tools. 92 93 $found_ca_rates = $wpdb->get_results( 93 94 $wpdb->prepare( … … 124 125 125 126 echo '<div class="updated inline"><p>'; 127 /* translators: %1$d: number of deleted rows */ 126 128 printf( esc_html__( 'Successfully deleted %1$d rows from the database.', 'woocommerce-services' ), intval( $deleted_count ) ); 127 129 echo '</p></div>'; … … 137 139 138 140 $deleted_count = absint( 141 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Core has no API for deleting transients by name pattern, and the tool is only registered when wp_using_ext_object_cache() is false (see woocommerce_debug_tools()), so the options table is the authoritative store here. It runs on an explicit click in WooCommerce » Status » Tools. 139 142 $wpdb->query( 140 143 "DELETE FROM {$wpdb->options} WHERE option_name LIKE '%tj\_tax\_%';" … … 143 146 144 147 echo '<div class="updated inline"><p>'; 148 /* translators: %1$d: number of deleted transients */ 145 149 printf( esc_html__( 'Successfully deleted %1$d transients from the database.', 'woocommerce-services' ), intval( $deleted_count ) ); 146 150 echo '</p></div>'; -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-error-notice.php
r3698279 r3732427 78 78 79 79 private function notice_enabled() { 80 return WC_Connect_Options::get_option( 'error_notice', false );80 return WC_Connect_Options::get_option( 'error_notice', false ); 81 81 } 82 82 … … 119 119 $product_id = $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id(); 120 120 $message = sprintf( 121 /* translators: %1$s: product edit URL, %2$s: product name */ 121 122 __( '<strong>"%2$s" is missing weight, length, width, or height.</strong><br />Shipping rates cannot be calculated. <a href="%1$s">Enter dimensions and weight for %2$s</a> so your customers can purchase this item.', 'woocommerce-services' ), 122 123 get_edit_post_link( $product_id ), -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-extension-compatibility.php
r3310537 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Extension_Compatibility' ) ) { -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-functions.php
r3685245 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Functions' ) ) { … … 19 23 ( defined( 'REST_REQUEST' ) || defined( 'REST_API_REQUEST' ) || defined( 'XMLRPC_REQUEST' ) ) || 20 24 // Skip during Jetpack REST API proxy requests. 25 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing check: it only detects that the current request is Jetpack's REST proxy so the cart API call is skipped. Nothing is written and there is no form submission to carry a nonce. 21 26 ( isset( $_GET['rest_route'] ) && isset( $_GET['_for'] ) && ( 'jetpack' === $_GET['_for'] ) ) 22 27 ); … … 153 158 154 159 // Export Tax Rates 160 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Full dump of WooCommerce's tax rate table for the CSV backup taken right before the CA rate deletion tool wipes rows; core exposes no reader for the whole table, and a cached copy would defeat the point of backing up the current contents. 155 161 $rates = $wpdb->get_results( 156 162 $wpdb->prepare( … … 170 176 $rows = array( 171 177 array( 172 __( 'Country Code', 'woocommerce ' ),173 __( 'State Code', 'woocommerce ' ),174 __( 'ZIP/Postcode', 'woocommerce ' ),175 __( 'City', 'woocommerce ' ),176 __( 'Rate %', 'woocommerce ' ),177 __( 'Tax Name', 'woocommerce ' ),178 __( 'Priority', 'woocommerce ' ),179 __( 'Compound', 'woocommerce ' ),180 __( 'Shipping', 'woocommerce ' ),181 __( 'Tax Class', 'woocommerce ' ),178 __( 'Country Code', 'woocommerce-services' ), 179 __( 'State Code', 'woocommerce-services' ), 180 __( 'ZIP/Postcode', 'woocommerce-services' ), 181 __( 'City', 'woocommerce-services' ), 182 __( 'Rate %', 'woocommerce-services' ), 183 __( 'Tax Name', 'woocommerce-services' ), 184 __( 'Priority', 'woocommerce-services' ), 185 __( 'Compound', 'woocommerce-services' ), 186 __( 'Shipping', 'woocommerce-services' ), 187 __( 'Tax Class', 'woocommerce-services' ), 182 188 ), 183 189 ); 184 190 185 191 foreach ( $rates as $rate ) { 192 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads WooCommerce's tax rate locations table, which has no core reader for a single rate's postcodes/cities. Part of the one-shot CSV backup above, so each rate is read once and a cache would never be reused. 186 193 $postcodes = $wpdb->get_col( $wpdb->prepare( "SELECT location_code FROM {$wpdb->prefix}woocommerce_tax_rate_locations WHERE location_type='postcode' AND tax_rate_id = %d ORDER BY location_code", $rate->tax_rate_id ) ); 187 $cities = $wpdb->get_col( $wpdb->prepare( "SELECT location_code FROM {$wpdb->prefix}woocommerce_tax_rate_locations WHERE location_type='city' AND tax_rate_id = %d ORDER BY location_code", $rate->tax_rate_id ) ); 194 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Same one-shot CSV backup read as the postcode query above, for the rate's city locations. 195 $cities = $wpdb->get_col( $wpdb->prepare( "SELECT location_code FROM {$wpdb->prefix}woocommerce_tax_rate_locations WHERE location_type='city' AND tax_rate_id = %d ORDER BY location_code", $rate->tax_rate_id ) ); 188 196 189 197 $rows[] = array( … … 338 346 foreach ( $files as $file ) { 339 347 if ( ! file_exists( trailingslashit( $file['base'] ) . $file['file'] ) ) { 340 $file_handle = @fopen( trailingslashit( $file['base'] ) . $file['file'], 'wb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_ read_fopen348 $file_handle = @fopen( trailingslashit( $file['base'] ) . $file['file'], 'wb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen 341 349 if ( $file_handle ) { 342 fwrite( $file_handle, $file['content'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_ read_fwrite343 fclose( $file_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_ read_fclose350 fwrite( $file_handle, $file['content'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite 351 fclose( $file_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose 344 352 } 345 353 } -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-help-view.php
r3505023 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Help_View' ) ) { … … 59 63 'state' => 'error', 60 64 'message' => sprintf( 65 /* translators: %1$s: minimum required WooCommerce version, %2$s: installed WooCommerce version */ 61 66 __( 'WooCommerce %1$s or higher is required (You are running %2$s)', 'woocommerce-services' ), 62 67 WOOCOMMERCE_CONNECT_MINIMUM_WOOCOMMERCE_VERSION, … … 73 78 'state' => 'success', 74 79 'message' => sprintf( 80 /* translators: %s: installed WooCommerce version */ 75 81 __( 'WooCommerce %s is configured correctly', 'woocommerce-services' ), 76 82 WC()->version … … 166 172 167 173 $subtitle = sprintf( 174 /* translators: %s: shipping zone name */ 168 175 __( '%s Shipping Zone', 'woocommerce-services' ), 169 176 $enabled_service->zone_name … … 329 336 'state' => 'error', 330 337 'settings_link_type' => '', 338 /* translators: %s: store country code */ 331 339 'message' => sprintf( __( 'Your store\'s country (%s) is not supported. Automated taxes functionality is disabled', 'woocommerce-services' ), $store_country ), 332 340 ); -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-jetpack.php
r3685245 r3732427 5 5 use Automattic\Jetpack\Status; 6 6 use Automattic\Jetpack\Status\Host; 7 8 if ( ! defined( 'ABSPATH' ) ) { 9 exit; 10 } 7 11 8 12 if ( ! class_exists( 'WC_Connect_Jetpack' ) ) { -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-label-reports.php
r3204770 r3732427 2 2 3 3 use Automattic\WooCommerce\Utilities\OrderUtil; 4 5 if ( ! defined( 'ABSPATH' ) ) { 6 exit; 7 } 4 8 5 9 if ( ! class_exists( 'WC_Connect_Label_Reports' ) ) { … … 19 23 20 24 public function get_export_button() { 21 $current_range = ! empty( $_GET['range'] ) ? sanitize_text_field( $_GET['range'] ) : '7day'; 25 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: the range only lands in the suggested CSV filename of the export link. WooCommerce core supplies this query arg on its own report screens, so there is no submission of ours to nonce. 26 $current_range = ! empty( $_GET['range'] ) ? sanitize_text_field( wp_unslash( $_GET['range'] ) ) : '7day'; 22 27 ?> 23 28 <a … … 41 46 $table_name = OrderUtil::get_table_for_order_meta(); 42 47 $id_column = OrderUtil::custom_orders_table_usage_is_enabled() ? 'order_id' : 'post_id'; 48 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk read of one meta key across every order; core offers no API for that, and wc_get_orders() would hydrate every order object. The result is cached by the only caller, query_labels(), in the wcs_label_reports transient for 30 minutes. 43 49 $db_results = $wpdb->get_results( 44 50 $wpdb->prepare( … … 129 135 ); 130 136 131 $current_range = ! empty( $_GET['range'] ) ? sanitize_text_field( $_GET['range'] ) : '7day'; 137 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only date-range selection for the report; the value is allow-listed below and WC_Admin_Report::check_current_range_nonce() on the next lines verifies core's nonce for the one range ('custom') that carries user input. 138 $current_range = ! empty( $_GET['range'] ) ? sanitize_text_field( wp_unslash( $_GET['range'] ) ) : '7day'; 132 139 133 140 if ( ! in_array( $current_range, array( 'custom', 'year', 'last_month', 'month', '7day' ) ) ) { … … 198 205 <tr> 199 206 <th scope="row"> 200 <?php echo esc_html( get_date_from_gmt( date( 'Y-m-d H:i:s', intval( $label['created'] / 1000 ) ) ) ); ?>207 <?php echo esc_html( get_date_from_gmt( gmdate( 'Y-m-d H:i:s', intval( $label['created'] / 1000 ) ) ) ); ?> 201 208 </th> 202 209 <td> -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-logger.php
r3452871 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Logger' ) ) { … … 105 109 * 106 110 * @param WP_Error|string $message Message to log 107 * @param string $context Optional context (e.g. a class or function name)111 * @param string $context Optional context (e.g. a class or function name) 108 112 */ 109 113 public function log( $message, $context = '', $force = false ) { … … 111 115 112 116 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { 113 // phpcs:ignore QITStandard.PHP.DebugCode.DebugFunctionFound -- Intended debug logging when WP_DEBUG is enabled.117 // phpcs:ignore QITStandard.PHP.DebugCode.DebugFunctionFound, WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intended debug logging when WP_DEBUG is enabled. 114 118 error_log( $log_message ); 115 119 } -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-migration-survey.php
r3350055 r3732427 8 8 9 9 use Automattic\WCServices\Utils; 10 11 if ( ! defined( 'ABSPATH' ) ) { 12 exit; 13 } 10 14 11 15 if ( ! class_exists( 'WC_Connect_Migration_Survey' ) ) { … … 42 46 public function should_show_survey() { 43 47 // Force survey to show for testing/debugging 48 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only debug switch that only decides whether the survey is localized into the label script; nothing is written here and the survey's own submit/dismiss/track AJAX handlers each verify the wcs_migration_survey nonce. 44 49 if ( isset( $_GET['force_survey'] ) ) { 45 50 return true; … … 110 115 111 116 // Get survey data 112 $survey_data = json_decode( stripslashes( $_POST['survey_data'] ), true ); 117 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON payload; each value is sanitized below before use. 118 $survey_data = isset( $_POST['survey_data'] ) ? json_decode( wp_unslash( $_POST['survey_data'] ), true ) : null; 113 119 114 120 if ( ! $survey_data ) { -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-nux.php
r3685245 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Nux' ) ) { … … 18 22 */ 19 23 const SHOULD_SHOW_AFTER_CXN_BANNER = 'should_display_nux_after_jp_cxn_banner'; 24 25 /** 26 * Nonce action guarding the "accept the Terms of Service" link on the connection banner. 27 */ 28 const ACCEPT_TOS_NONCE_ACTION = 'wcs-nux-tos-accept'; 29 30 /** 31 * Nonce action guarding the "dismiss" link on the post-connection banner. 32 */ 33 const DISMISS_AFTER_CXN_BANNER_NONCE_ACTION = 'wcs-nux-notice-dismiss'; 20 34 21 35 /** … … 136 150 global $wpdb; 137 151 152 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Existence probe for one meta key across all posts; core has no API for "does any post carry this meta". The answer is memoised in the IS_NEW_LABEL_USER transient a few lines below, so this runs once per transient lifetime. 138 153 $results = $wpdb->get_results( 139 154 $wpdb->prepare( … … 175 190 '<h3>%s</h3><p>%s</p>', 176 191 __( 'Discounted Shipping Labels', 'woocommerce-services' ), 192 /* translators: %s: list of supported carriers */ 177 193 sprintf( __( "When you're ready, purchase and print discounted labels from %s right here.", 'woocommerce-services' ), implode( ' or ', $supported_carriers ) ) 178 194 ), … … 300 316 301 317 /** 318 * Whether the current user may accept the Terms of Service. 319 * 320 * Only the Jetpack connection owner may, or any user while Jetpack is in offline mode. 321 * This checks no capability, so in offline mode it is true for every user: callers must 322 * check capabilities themselves. 323 * 324 * @since 3.7.1 325 * 326 * @return bool 327 */ 328 public static function current_user_can_accept_tos() { 329 return WC_Connect_Jetpack::is_current_user_connection_owner() || WC_Connect_Jetpack::is_offline_mode(); 330 } 331 332 /** 302 333 * Whether the WooCommerce Tax connection banner may render on the given screen. 303 334 * … … 320 351 321 352 return 'woocommerce_page_wc-settings' === $screen->base 353 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen check: WooCommerce puts the settings tab in the URL, and this only decides whether the banner is rendered on it. No state is touched here. 322 354 && isset( $_GET['tab'] ) && 'tax' === $_GET['tab']; 323 355 } … … 385 417 // If this is the case, the admin can connect the site on their own, and should be able to use WCS as ususal 386 418 $jetpack_install_status = $this->get_jetpack_install_status(); 419 $can_accept_tos = self::current_user_can_accept_tos(); 387 420 388 421 $banner_to_display = self::get_banner_type_to_display( … … 390 423 'jetpack_connection_status' => $jetpack_install_status, 391 424 'tos_accepted' => WC_Connect_Options::get_option( 'tos_accepted' ), 392 'can_accept_tos' => WC_Connect_Jetpack::is_current_user_connection_owner() || WC_Connect_Jetpack::is_offline_mode(), 393 'should_display_after_cxn_banner' => WC_Connect_Options::get_option( self::SHOULD_SHOW_AFTER_CXN_BANNER ), 425 'can_accept_tos' => $can_accept_tos, 426 // The after-connection banner accepts the terms on render, so only a user who may accept gets it. 427 'should_display_after_cxn_banner' => $can_accept_tos && WC_Connect_Options::get_option( self::SHOULD_SHOW_AFTER_CXN_BANNER ), 394 428 'is_site_only_connection' => WC_Connect_Jetpack::is_site_only_connection(), 395 429 ) … … 518 552 } 519 553 554 /** 555 * Verifies the nonce carried by a banner link before it is allowed to change any state. 556 * 557 * The banner links are plain GET links rendered into an admin notice, so without this 558 * an attacker could make a logged-in admin accept the Terms of Service or dismiss the 559 * banner just by getting them to load a URL. 560 * 561 * @param string $action Nonce action the link was created with. 562 * @return bool True when the request carries a valid nonce for $action. 563 */ 564 private static function is_nux_action_verified( $action ) { 565 if ( empty( $_GET['_wpnonce'] ) ) { 566 return false; 567 } 568 569 return (bool) wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), $action ); 570 } 571 520 572 public function show_banner_after_connection() { 521 573 if ( ! $this->should_display_nux_notice_for_current_store_locale() ) { … … 528 580 529 581 // Did the user just dismiss? 530 if ( isset( $_GET['wcs-nux-notice'] ) && 'dismiss' === $_GET['wcs-nux-notice'] ) { 582 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The nonce is verified by self::is_nux_action_verified() on the next line before anything is written. 583 if ( isset( $_GET['wcs-nux-notice'] ) && 'dismiss' === $_GET['wcs-nux-notice'] 584 && self::is_nux_action_verified( self::DISMISS_AFTER_CXN_BANNER_NONCE_ACTION ) ) { 531 585 // No longer need to keep track of whether the before connection banner was displayed. 532 586 WC_Connect_Options::delete_option( self::SHOULD_SHOW_AFTER_CXN_BANNER ); 533 wp_safe_redirect( remove_query_arg( 'wcs-nux-notice') );587 wp_safe_redirect( remove_query_arg( array( 'wcs-nux-notice', '_wpnonce' ) ) ); 534 588 exit; 589 } 590 591 // Only a user who may accept the terms accepts them by seeing this banner. 592 if ( ! self::current_user_can_accept_tos() ) { 593 if ( ! WC_Connect_Options::get_option( 'tos_accepted' ) ) { 594 $this->show_tos_informational_banner(); 595 } 596 return; 535 597 } 536 598 … … 550 612 'description' => esc_html( sprintf( $description_base, $feature_list ) ), 551 613 'button_text' => __( 'Got it, thanks!', 'woocommerce-services' ), 552 'button_link' => add_query_arg( 553 array( 554 'wcs-nux-notice' => 'dismiss', 555 ) 614 'button_link' => wp_nonce_url( 615 add_query_arg( 616 array( 617 'wcs-nux-notice' => 'dismiss', 618 ) 619 ), 620 self::DISMISS_AFTER_CXN_BANNER_NONCE_ACTION 556 621 ), 557 622 'image_url' => plugins_url( … … 573 638 } 574 639 575 if ( isset( $_GET['wcs-nux-tos'] ) && 'accept' === $_GET['wcs-nux-tos'] ) { 640 // Only a user who may accept the terms gets the link that accepts them. 641 if ( ! self::current_user_can_accept_tos() ) { 642 if ( ! WC_Connect_Options::get_option( 'tos_accepted' ) ) { 643 $this->show_tos_informational_banner(); 644 } 645 return; 646 } 647 648 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The nonce is verified by self::is_nux_action_verified() on the next line before the Terms of Service acceptance is stored. 649 if ( isset( $_GET['wcs-nux-tos'] ) && 'accept' === $_GET['wcs-nux-tos'] 650 && self::is_nux_action_verified( self::ACCEPT_TOS_NONCE_ACTION ) ) { 576 651 WC_Connect_Options::update_option( 'tos_accepted', true ); 577 652 578 653 $this->tracks->opted_in( 'tos_banner' ); 579 654 580 wp_safe_redirect( remove_query_arg( 'wcs-nux-tos') );655 wp_safe_redirect( remove_query_arg( array( 'wcs-nux-tos', '_wpnonce' ) ) ); 581 656 exit; 582 657 } … … 592 667 'description' => esc_html( sprintf( $description_base, $feature_list ) ), 593 668 'button_text' => __( 'Connect', 'woocommerce-services' ), 594 'button_link' => add_query_arg( 595 array( 596 'wcs-nux-tos' => 'accept', 597 ) 669 'button_link' => wp_nonce_url( 670 add_query_arg( 671 array( 672 'wcs-nux-tos' => 'accept', 673 ) 674 ), 675 self::ACCEPT_TOS_NONCE_ACTION 598 676 ), 599 677 'image_url' => plugins_url( … … 653 731 <p class="wcs-nux__notice-content-tos"> 654 732 <?php 655 /* translators: %1$s example values include "Install Jetpack and CONNECT >", "Activate Jetpack and CONNECT >", "CONNECT >" */656 733 printf( 657 734 wp_kses( 735 /* translators: %1$s: button text, example values include "Install Jetpack and CONNECT >", "Activate Jetpack and CONNECT >", "CONNECT >", %2$s: Terms of Service URL, %3$s: Privacy Policy URL */ 658 736 __( 'By clicking "%1$s", you agree to our <a href="%2$s">Terms of Service</a> and have read our <a href="%3$s">Privacy Policy</a>.', 'woocommerce-services' ), 659 737 array( -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-options.php
r3452871 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Options' ) ) { … … 138 142 $names = (array) $names; 139 143 if ( ! self::is_valid( $names ) ) { 140 wp_trigger_error( 'WC_Connect_Options::delete_option', esc_html( sprintf( 'Invalid WooCommerce Tax option names: %s', print_r( $names, 1) ) ), E_USER_WARNING );144 wp_trigger_error( 'WC_Connect_Options::delete_option', esc_html( sprintf( 'Invalid WooCommerce Tax option names: %s', wp_json_encode( $names ) ) ), E_USER_WARNING ); 141 145 return false; 142 146 } … … 332 336 private static function delete_all_shipping_methods_options() { 333 337 global $wpdb; 338 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Needs the raw method_id/instance_id rows of every shipping zone method; WC_Shipping_Zones would instantiate each method object, which is exactly what must not happen while the plugin's options are being torn down. Runs once, on uninstall (WC_Connect_Loader::plugin_uninstall()), so there is nothing to cache. 334 339 $methods = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zone_methods " ); 335 340 -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-order-presenter.php
r2818899 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Order_Presenter' ) ) { -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-payment-gateway.php
r1586050 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Payment_Gateway' ) ) { … … 12 16 13 17 $this->init_settings(); 14 15 18 } 16 17 19 } 18 20 19 21 } 20 -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-payment-methods-store.php
r3097468 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Payment_Methods_Store' ) ) { … … 20 24 protected $logger; 21 25 22 public function __construct( WC_Connect_Service_Settings_Store $service_settings_store, 23 WC_Connect_API_Client $api_client, WC_Connect_Logger $logger ) { 26 public function __construct( 27 WC_Connect_Service_Settings_Store $service_settings_store, 28 WC_Connect_API_Client $api_client, 29 WC_Connect_Logger $logger 30 ) { 24 31 25 32 $this->service_settings_store = $service_settings_store; 26 33 $this->api_client = $api_client; 27 34 $this->logger = $logger; 28 29 35 } 30 36 -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-paypal-ec.php
r3289651 r3732427 135 135 sprintf( 136 136 wp_kses( 137 /* translators: %s: PayPal signup URL */ 137 138 __( 'To issue refunds via PayPal Checkout, you will need to <a href="%s">link a PayPal account</a> with the email address that received this payment.', 'woocommerce-services' ), 138 139 array( 'a' => array( 'href' => array() ) ) … … 194 195 || ( // WooCommerce » Settings » Payments. 195 196 'woocommerce_page_wc-settings' === $screen->base 197 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen check: WooCommerce puts its settings tab in the URL, and this only decides whether the PayPal banner is enqueued. Nothing is written. 196 198 && isset( $_GET['tab'] ) && 'checkout' === $_GET['tab'] 197 199 ) 198 200 || ( // WooCommerce » Extensions » Payments. 199 201 'woocommerce_page_wc-addons' === $screen->base 202 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen check, as above: the section query arg is WooCommerce's own and only selects where the banner renders. 200 203 && isset( $_GET['section'] ) && 'payment-gateways' === $_GET['section'] 201 204 ) … … 267 270 268 271 // Prevent user from changing Payment Action away from "Sale", the only option for which payments will work 269 $form_fields['paymentaction']['disabled'] = true; 272 $form_fields['paymentaction']['disabled'] = true; 273 /* translators: %s: existing description of the payment action setting */ 270 274 $form_fields['paymentaction']['description'] = sprintf( __( '%s (Note that "authorizing payment only" requires linking a PayPal account.)', 'woocommerce-services' ), $form_fields['paymentaction']['description'] ); 271 275 272 276 // Communicate WCS proxying and provide option to disable 273 $reset_link = add_query_arg(277 $reset_link = add_query_arg( 274 278 array( 275 279 'reroute_requests' => 'no', … … 278 282 wc_gateway_ppec()->get_admin_setting_link() 279 283 ); 284 /* translators: %s: URL that resets the PayPal API credentials */ 280 285 $api_creds_template = __( 'Payments will be authenticated by WooCommerce Tax and directed to the following email address. To disable this feature and link a PayPal account, <a href="%s">click here</a>.', 'woocommerce-services' ); 281 286 if ( empty( $settings->api_username ) ) { … … 291 296 } else { 292 297 // Provide option to enable request proxying 293 $reset_link = add_query_arg(298 $reset_link = add_query_arg( 294 299 array( 295 300 'reroute_requests' => 'yes', … … 298 303 wc_gateway_ppec()->get_admin_setting_link() 299 304 ); 305 /* translators: %s: URL that resets the PayPal API credentials */ 300 306 $api_creds_template = __( 'To authenticate payments with WooCommerce Tax, <a href="%s">click here</a>.', 'woocommerce-services' ); 301 307 if ( empty( $settings->api_username ) ) { … … 339 345 empty( $_GET['reroute_requests'] ) || 340 346 empty( $_GET['nonce'] ) || 341 ! wp_verify_nonce( $_GET['nonce'], 'reroute_requests' ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized347 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['nonce'] ) ), 'reroute_requests' ) 342 348 ) { 343 349 return; -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-privacy.php
r3289651 r3732427 38 38 sprintf( 39 39 wp_kses( 40 /* translators: %s: URL of the privacy documentation */ 40 41 __( 'By using this extension, you may be storing personal data or sharing data with external services. <a href="%s" target="_blank">Learn more about how this works, including what you may want to include in your privacy policy.</a>', 'woocommerce-services' ), 41 42 array( -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-service-schemas-store.php
r3289651 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Service_Schemas_Store' ) ) { … … 209 213 public function get_service_schema_by_instance_id( $instance_id ) { 210 214 global $wpdb; 215 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Only the method_id string is wanted; WC_Shipping_Zones::get_shipping_method() would instantiate the shipping method, and this runs from inside shipping method code, so that would recurse. Caching is skipped because a zone edit can repoint an instance_id at a different method and WooCommerce fires no hook this store could invalidate on. 211 216 $method_id = $wpdb->get_var( 212 217 $wpdb->prepare( -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-service-schemas-validator.php
r2498257 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Service_Schemas_Validator' ) ) { … … 45 49 } 46 50 47 $service_counter ++;51 ++$service_counter; 48 52 } 49 53 … … 190 194 return true; 191 195 } 192 193 196 } 194 197 -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-service-settings-store.php
r3327549 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Service_Settings_Store' ) ) { … … 262 266 * Updates the existing label data 263 267 * 264 * @param $order_id 265 * @param $new_label_data 266 * 267 * @return array updated label info 268 * @param int $order_id Order ID. 269 * @param object $new_label_data Label data from the Connect server, with a label_id. 270 * 271 * @return array|object The updated label info, or $new_label_data unchanged when the order 272 * does not have that label. 268 273 */ 269 274 public function update_label_order_meta_data( $order_id, $new_label_data ) { 270 $result = $new_label_data; 271 $order = wc_get_order( $order_id ); 275 $result = $new_label_data; 276 $order = wc_get_order( $order_id ); 277 278 // Nothing to update when the ID is not an order. 279 if ( ! $order instanceof WC_Order ) { 280 return $result; 281 } 282 272 283 $labels_data = $this->get_label_order_meta_data( $order_id ); 284 $matched = false; 273 285 foreach ( $labels_data as $index => $label_data ) { 274 if ( $label_data['label_id'] === $new_label_data->label_id ) { 286 if ( is_array( $label_data ) && isset( $label_data['label_id'] ) && (int) $label_data['label_id'] === (int) $new_label_data->label_id ) { 287 $matched = true; 275 288 $result = array_merge( $label_data, (array) $new_label_data ); 276 289 $labels_data[ $index ] = $result; … … 282 295 } 283 296 } 297 298 // Not this order's label: write nothing, not even an empty list, which counts as a label. 299 if ( ! $matched ) { 300 return $result; 301 } 302 284 303 $order->update_meta_data( 'wc_connect_labels', $labels_data ); 285 304 $order->save(); … … 291 310 * 292 311 * @param $order_id 293 * @param array $new_labels - labels to be added312 * @param array $new_labels - labels to be added 294 313 */ 295 314 public function add_labels_to_order( $order_id, $new_labels ) { 315 $order = wc_get_order( $order_id ); 316 317 // Nothing to add to when the ID is not an order. 318 if ( ! $order instanceof WC_Order ) { 319 return; 320 } 321 296 322 $labels_data = $this->get_label_order_meta_data( $order_id ); 297 323 $labels_data = array_merge( $new_labels, $labels_data ); 298 $order = wc_get_order( $order_id );299 324 $order->update_meta_data( 'wc_connect_labels', $labels_data ); 300 325 $order->save(); … … 306 331 307 332 public function update_destination_address( $order_id, $api_address ) { 308 $order = wc_get_order( $order_id ); 333 $order = wc_get_order( $order_id ); 334 335 // Nothing to update when the ID is not an order. 336 if ( ! $order instanceof WC_Order ) { 337 return; 338 } 339 309 340 $wc_address = $order->get_address( 'shipping' ); 310 341 … … 361 392 362 393 public function get_enabled_services_by_ids( $service_ids ) { 363 if ( empty( $service_ids ) ) {394 if ( empty( $service_ids ) || ! is_array( $service_ids ) ) { 364 395 return array(); 365 396 } … … 367 398 $enabled_services = array(); 368 399 369 // Note: We use esc_sql here instead of prepare because we are using WHERE IN 370 // https://codex.wordpress.org/Function_Reference/esc_sql. 371 372 $escaped_list = ''; 373 foreach ( $service_ids as $shipping_service ) { 374 if ( ! empty( $escaped_list ) ) { 375 $escaped_list .= ','; 376 } 377 $escaped_list .= "'" . esc_sql( $shipping_service ) . "'"; 378 } 400 // prepare() cannot take a list, but it can take a generated run of placeholders: 401 // one %s per service id, with the ids passed through as the arguments. 402 $service_ids = array_values( $service_ids ); 403 $placeholders = implode( ', ', array_fill( 0, count( $service_ids ), '%s' ) ); 379 404 380 405 global $wpdb; 381 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared --- Need to use interpolated for the `IN()` condition 406 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- The only interpolations are $wpdb->prefix and $placeholders, a generated run of %s built above from count( $service_ids ); the ids themselves are bound by prepare(). 407 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Joins WooCommerce's shipping zone tables, which have no core reader that returns raw rows; WC_Shipping_Zones would instantiate every shipping method. Not cached because zone methods are edited from WooCommerce's own screens, which fire no hook this store could invalidate on. 382 408 $methods = $wpdb->get_results( 383 "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zone_methods " . 384 "LEFT JOIN {$wpdb->prefix}woocommerce_shipping_zones " . 385 "ON {$wpdb->prefix}woocommerce_shipping_zone_methods.zone_id = {$wpdb->prefix}woocommerce_shipping_zones.zone_id " . 386 "WHERE method_id IN ({$escaped_list}) " . 387 'ORDER BY zone_order, instance_id;' 409 $wpdb->prepare( 410 "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zone_methods " . 411 "LEFT JOIN {$wpdb->prefix}woocommerce_shipping_zones " . 412 "ON {$wpdb->prefix}woocommerce_shipping_zone_methods.zone_id = {$wpdb->prefix}woocommerce_shipping_zones.zone_id " . 413 "WHERE method_id IN ({$placeholders}) " . 414 'ORDER BY zone_order, instance_id;', 415 $service_ids 416 ) 388 417 ); 389 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 418 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 390 419 391 420 if ( empty( $methods ) ) { … … 445 474 $new_method_id = $service_schema->method_id; 446 475 476 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Rewrites the stored method_id of a zone method row; core exposes no API for renaming a shipping method in place. One-shot migration guarded by the shipping_methods_migrated option, and a write has nothing to cache. 447 477 $wpdb->update( 448 478 "{$wpdb->prefix}woocommerce_shipping_zone_methods", -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-settings-pages.php
r3310537 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Settings_Pages' ) ) { … … 115 119 } 116 120 121 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: the order id only becomes a "back to order" link in the settings screen's bootstrap. This renders a screen, it does not handle a submission, so there is no nonce to verify. 117 122 if ( isset( $_GET['from_order'] ) ) { 118 $from_order = sanitize_text_field( $_GET['from_order'] ); 123 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Same read-only screen argument as the isset() above. 124 $from_order = sanitize_text_field( wp_unslash( $_GET['from_order'] ) ); 119 125 $extra_args['order_id'] = $from_order; 120 126 $extra_args['order_href'] = get_edit_post_link( $from_order ); 121 127 } 122 128 129 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: picks which carrier's section of the settings screen to render. No state is changed and no form is being processed. 123 130 if ( ! empty( $_GET['carrier'] ) ) { 124 $carrier = sanitize_text_field( $_GET['carrier'] ); 131 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Same read-only screen argument as the empty() check above. 132 $carrier = sanitize_text_field( wp_unslash( $_GET['carrier'] ) ); 125 133 $extra_args['carrier'] = $carrier; 126 134 $extra_args['continents'] = $this->continents->get(); -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-shipping-label.php
r3350055 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Shipping_Label' ) ) { -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-shipping-method.php
r3327549 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Shipping_Method' ) ) { -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-taxjar-integration.php
r3679638 r3732427 3 3 use Automattic\WCServices\StoreNotices\StoreNoticesNotifier; 4 4 use Automattic\WCServices\Tax\Address; 5 use Automattic\WCServices\Tax\StoreAddressNotice; 6 use Automattic\WCServices\Tax\StoreAddressVerifier; 5 7 6 8 class WC_Connect_TaxJar_Integration { … … 17 19 18 20 /** 19 * @var StoreNoticesNotifier 21 * Shows customer-input errors at checkout. Optional: null when none is passed to the constructor. 22 * 23 * @var StoreNoticesNotifier|null 20 24 */ 21 25 private $notifier; … … 68 72 69 73 /** 74 * Rate ids TaxJar returned for an admin recalculation, keyed by order id, then by 75 * order item id. Kept apart from $response_rate_ids, which other code reads as 76 * "this request is the cart flow". Each entry is used once, see 77 * take_admin_recalculation_rate_ids(). 78 * 79 * @var array<int, array<int, int[]>> 80 */ 81 private $admin_recalculation_rate_ids = array(); 82 83 /** 70 84 * Tax snapshots captured before an out-of-cart recalculation, keyed by order id, 71 85 * so they can be restored after WC recalculates the order totals. … … 74 88 */ 75 89 private $pre_recalculation_tax_snapshots = array(); 90 91 /** 92 * Taxable amounts of order items as they were before an in-request save changed 93 * them, keyed by item id. The REST API saves an edited item before it 94 * recalculates the order, so this is the only place the old amount is visible. 95 * 96 * @var array 97 */ 98 private $order_item_base_before_save = array(); 99 100 /** 101 * Billing and shipping addresses of orders as they were before an in-request save 102 * changed them, keyed by order id. Code that saves an order before recalculating it 103 * would otherwise leave no trace of the old address. 104 * 105 * @var array 106 */ 107 private $order_address_before_save = array(); 108 109 /** 110 * The location WooCommerce taxed each order item for in this recalculation, keyed by 111 * spl_object_id(), as it passed it to the item's after-calculate-taxes hook. Only kept 112 * for orders being preserved, so the hook can run again on the tax set here. 113 * 114 * @var array<int, array> 115 */ 116 private $order_item_tax_locations = array(); 117 118 /** 119 * Order items being created, like $orders_created_in_request. WC_Order::add_product() 120 * saves the item at once, so it is already saved when the order is recalculated. 121 * 122 * @var array{objects: array<int, bool>, ids: array<int, bool>} 123 */ 124 private $order_items_created_in_request = array( 125 'objects' => array(), 126 'ids' => array(), 127 ); 128 129 /** 130 * Orders being created: spl_object_id() of each order object saved without an id, 131 * then the id it was given. A new order had no earlier address its tax was based on. 132 * 133 * @var array{objects: array<int, bool>, ids: array<int, bool>} 134 */ 135 private $orders_created_in_request = array( 136 'objects' => array(), 137 'ids' => array(), 138 ); 76 139 77 140 /** … … 108 171 const OPTION_NAME = 'wc_connect_taxes_enabled'; 109 172 const SETUP_WIZARD_OPTION_NAME = 'woocommerce_setup_automated_taxes'; 173 174 /** 175 * Returned by lookup_order_taxes() when it failed because the order's address 176 * cannot be sent to TaxJar, as opposed to TaxJar failing to answer. 177 */ 178 private const ORDER_TAX_LOOKUP_BAD_ADDRESS = 'bad_address'; 179 180 /** 181 * Returned by lookup_order_taxes() when it failed because the store's own address 182 * cannot be sent to TaxJar. 183 */ 184 private const ORDER_TAX_LOOKUP_BAD_STORE_ADDRESS = 'bad_store_address'; 185 186 /** 187 * WooCommerce's after-calculate-taxes hook for each order item type. 188 */ 189 private const ORDER_ITEM_TAX_HOOKS = array( 190 'line_item' => 'woocommerce_order_item_after_calculate_taxes', 191 'fee' => 'woocommerce_order_item_fee_after_calculate_taxes', 192 'shipping' => 'woocommerce_order_item_shipping_after_calculate_taxes', 193 ); 110 194 111 195 /** … … 182 266 } 183 267 268 /** 269 * Name of the 0% row that keeps a merchant's state-wide rate from applying to a looked-up town. 270 * 271 * The name is stored with the row and copied to any order it taxes, so it has to say 272 * why the merchant's own rate does not apply there. US rows carry the same county and 273 * city prefix as the rows TaxJar's rates are stored in. 274 * 275 * @param string $to_country The destination country. 276 * @param array $jurisdictions Tax jurisdictions. 277 * 278 * @return string 279 */ 280 private static function generate_nullified_rate_name( string $to_country, array $jurisdictions ) { 281 /* translators: Name of a 0% tax rate added when automated taxes replace a store's manual rate for one town. */ 282 $label = __( 'Manual Rate Nullified (Automated Taxes)', 'woocommerce-services' ); 283 284 if ( 'US' !== $to_country ) { 285 return $label; 286 } 287 288 $county = isset( $jurisdictions['county'] ) ? trim( (string) $jurisdictions['county'] ) : ''; 289 $city = isset( $jurisdictions['city'] ) ? trim( (string) $jurisdictions['city'] ) : ''; 290 $jurisdiction = trim( $county . ' ' . $city ); 291 292 return ( '' !== $jurisdiction ? $jurisdiction . ' : ' : '' ) . $label; 293 } 294 184 295 public function init() { 185 296 // Only enable WCS TaxJar integration if the official TaxJar plugin isn't active. … … 226 337 $this->configure_tax_settings(); 227 338 339 // Check the store address with TaxJar when it changes, and tell the merchant when it looks wrong. 340 $store_address_verifier = new StoreAddressVerifier( $this->api_client, $this ); 341 $store_address_verifier->init(); 342 ( new StoreAddressNotice( $store_address_verifier ) )->init(); 343 228 344 // Calculate Taxes at Cart / Checkout 229 345 if ( class_exists( 'WC_Cart_Totals' ) ) { // Woo 3.2+ … … 238 354 // Preserve recorded taxes when an existing order is recalculated outside the 239 355 // cart/checkout and admin flows (e.g. a REST API or programmatic order update), 240 // so a changed address does not wipe the stored tax lines to zero. 356 // so a changed address does not wipe the stored tax lines to zero. When the 357 // order's amounts change, the rates recorded on the order are re-applied. When 358 // its address changes, or a new order has no tax yet, TaxJar is asked for rates. 241 359 add_action( 'woocommerce_order_before_calculate_taxes', array( $this, 'preserve_order_taxes_on_recalculation' ), 10, 2 ); 360 foreach ( self::ORDER_ITEM_TAX_HOOKS as $after_calculate_taxes ) { 361 add_action( $after_calculate_taxes, array( $this, 'remember_order_item_tax_location' ), PHP_INT_MIN, 2 ); 362 } 363 add_action( 'woocommerce_before_order_item_object_save', array( $this, 'remember_order_item_base_before_save' ), 10, 1 ); 364 add_action( 'woocommerce_after_order_item_object_save', array( $this, 'remember_order_item_created' ), 10, 1 ); 365 add_action( 'woocommerce_before_order_object_save', array( $this, 'remember_order_before_save' ), 10, 1 ); 366 add_action( 'woocommerce_after_order_object_save', array( $this, 'remember_order_created' ), 10, 1 ); 242 367 243 368 // Set customer taxable location for local pickup … … 284 409 $backedup_tax_rates_url = admin_url( '/admin.php?page=wc-status&tab=connect#tax-rate-backups' ); 285 410 411 /* translators: %1$s: opening link tag, %2$s: closing link tag */ 286 412 $powered_by_wct_notice = '<p>' . sprintf( __( 'Automated taxes take over from the WooCommerce core tax settings. This means that "Display prices" will be set to Excluding tax and tax will be Calculated using Customer shipping address. %1$sLearn more about Automated taxes here.%2$s', 'woocommerce-services' ), '<a href="https://woocommerce.com/document/woocommerce-shipping-and-tax/woocommerce-tax/#setup-and-configuration">', '</a>' ) . '</p>'; 287 413 414 /* translators: %1$s: opening link tag, %2$s: closing link tag */ 288 415 $backup_notice = ( ! empty( WC_Connect_Functions::get_backed_up_tax_rate_files() ) ) ? '<p>' . sprintf( __( 'Your previous tax rates were backed up and can be downloaded %1$shere%2$s.', 'woocommerce-services' ), '<a href="' . esc_url( $backedup_tax_rates_url ) . '">', '</a>' ) . '</p>' : ''; 289 416 290 417 $desctructive_action_notice = '<p>' . __( 'Enabling this option overrides any tax rates you have manually added.', 'woocommerce-services' ) . '</p>'; 418 /* translators: %1$s: opening link tag, %2$s: closing link tag */ 291 419 $desctructive_backup_notice = '<p>' . sprintf( __( 'Your existing tax rates will be backed-up to a CSV that you can download %1$shere%2$s.', 'woocommerce-services' ), '<a href="' . esc_url( $backedup_tax_rates_url ) . '">', '</a>' ) . '</p>'; 292 420 … … 357 485 public function fix_tooltip_keepalive() { 358 486 global $pagenow; 487 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen check on WooCommerce's own page/tab/section query args; it only decides whether the tooltip's inline script is added on WooCommerce » Settings » Tax. Nothing is written. 359 488 if ( 'admin.php' !== $pagenow || ! isset( $_GET['page'] ) || 'wc-settings' !== $_GET['page'] || ! isset( $_GET['tab'] ) || 'tax' !== $_GET['tab'] || ! empty( $_GET['section'] ) ) { 360 489 return; … … 509 638 $formatted_message = is_scalar( $message ) ? $message : json_encode( $message ); 510 639 511 // ignore error messages caused by customer input 640 // Show errors caused by customer input to the customer through the notifier, when there is 641 // one, instead of logging them. Only where there is a customer to show them to: REST, cron 642 // and WP-CLI requests have no WC session, so those errors are logged. 512 643 $state_zip_mismatch = false !== strpos( $formatted_message, 'to_zip' ) && false !== strpos( $formatted_message, 'is not used within to_state' ); 513 644 $invalid_postcode = false !== strpos( $formatted_message, 'isn\'t a valid postal code for' ); 514 $malformed_postcode = false !== strpos( $formatted_message, 'zip code has incorrect format' ); 515 if ( ! is_admin() && ( $state_zip_mismatch || $invalid_postcode || $malformed_postcode ) ) { 645 // Only the shopper's ZIP. A bad store ZIP is the merchant's to fix, so it is logged and checkout goes on. 646 $malformed_postcode = false !== strpos( $formatted_message, 'Country destination is set to US but the zip code has incorrect format' ); 647 if ( ! is_admin() && StoreNoticesNotifier::wc_session_exists() && ( $state_zip_mismatch || $invalid_postcode || $malformed_postcode ) ) { 648 if ( ! $this->notifier ) { 649 // The notifier is optional. Without one, log the error, but through log() rather 650 // than error(): customer input must not replace the admin error notice. 651 $this->logger->log( $formatted_message, 'WCS Tax' ); 652 653 return; 654 } 655 516 656 $fields = WC()->countries->get_address_fields(); 517 657 $postcode_field_name = __( 'ZIP/Postal code', 'woocommerce-services' ); … … 521 661 522 662 if ( $state_zip_mismatch ) { 663 /* translators: %s: ZIP/Postal code checkout field label */ 523 664 $message = sprintf( _x( '%s does not match the selected state.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name ); 524 665 } elseif ( $malformed_postcode ) { 666 /* translators: %s: ZIP/Postal code checkout field label */ 525 667 $message = sprintf( _x( '%s is not formatted correctly.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name ); 526 668 } else { 669 /* translators: %s: ZIP/Postal code checkout field label */ 527 670 $message = sprintf( _x( 'Invalid %s entered.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name ); 528 671 } … … 653 796 * Calculate tax / totals using TaxJar for backend orders 654 797 * 655 * Unchanged fromthe TaxJar plugin.798 * Based on the TaxJar plugin. 656 799 * See: https://github.com/taxjar/taxjar-woocommerce-plugin/blob/96b5d57/includes/class-wc-taxjar-integration.php#L557 657 800 * … … 660 803 public function calculate_backend_totals( $order_id ) { 661 804 $order = wc_get_order( $order_id ); 662 $address = $this-> get_backend_address();805 $address = $this->fill_missing_backend_street( $this->get_backend_address(), $order ); 663 806 $line_items = $this->get_backend_line_items( $order ); 664 807 if ( method_exists( $order, 'get_shipping_total' ) ) { … … 678 821 ) 679 822 ); 680 if ( class_exists( 'WC_Order_Item_Tax' ) ) { // Add tax rates manually for Woo 3.0+ 681 /** 682 * @var WC_Order_Item_Product $item Product Order Item. 683 */ 684 foreach ( $order->get_items() as $item_key => $item ) { 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 ] ) ) { 689 $rate_id = $taxes['rate_ids'][ $line_item_key ]; 690 $item_tax = new WC_Order_Item_Tax(); 691 $item_tax->set_rate( $rate_id ); 692 $item_tax->set_order_id( $order_id ); 693 $item_tax->save(); 823 824 /* 825 * WooCommerce recalculates the order after this hook by reading the rate table 826 * for the address. That can return more than TaxJar did: a merchant's row at a 827 * priority none of the returned components uses stacks on top of them. Keep the 828 * rate ids TaxJar returned per order item so override_order_item_taxes() can 829 * apply exactly those, as checkout does. Without an answer, WooCommerce's 830 * result stands, as before. 831 */ 832 $this->admin_recalculation_rate_ids[ (int) $order_id ] = array(); 833 if ( is_array( $taxes ) && ! empty( $taxes['rate_ids'] ) ) { 834 foreach ( $line_items as $item_key => $line_item ) { 835 $rate_ids = $taxes['rate_ids'][ $line_item['id'] ?? '' ] ?? null; 836 if ( is_array( $rate_ids ) && $rate_ids ) { 837 $this->admin_recalculation_rate_ids[ (int) $order_id ][ (int) $item_key ] = $rate_ids; 694 838 } 695 839 } 696 } elseif ( class_exists( 'WC_AJAX' ) ) { // Recalculate tax for Woo 2.6 to apply new tax rates 697 remove_action( 'woocommerce_before_save_order_items', array( $this, 'calculate_backend_totals' ), 20 ); 698 if ( check_ajax_referer( 'calc-totals', 'security', false ) ) { 699 WC_AJAX::calc_line_taxes(); 700 } 701 add_action( 'woocommerce_before_save_order_items', array( $this, 'calculate_backend_totals' ), 20 ); 702 } 840 } 841 842 /* 843 * No tax items are saved here. wc_save_order_items(), which fires this hook, 844 * rebuilds the order's tax items from its line items right after, and 845 * Recalculate then recalculates them, so WooCommerce adds one per rate the 846 * order is charged under that rate's own name. 847 */ 703 848 } 704 849 … … 969 1114 970 1115 /** 1116 * Fill in the street when an order-items request arrived without one. 1117 * 1118 * Core's Recalculate button and line-item delete both post country, state, postcode 1119 * and city, and the order-screen script (`client/new-order-taxjar.js`) adds `street` 1120 * to each. When that script did not run (not loaded, stopped by another script's 1121 * error, or another handler replaced the request data), the key is missing and 1122 * TaxJar would get no street. 1123 * 1124 * The order's saved street is used only when the posted address matches the saved 1125 * address on the side core read it from. Otherwise the merchant edited the address 1126 * without saving the order, and pairing the new ZIP with the old street would be 1127 * worse than sending no street. A street posted empty is the merchant's input and is 1128 * left alone. 1129 * 1130 * @param array|mixed $address Backend address in the `to_*` shape from `get_backend_address()`. 1131 * @param WC_Order|bool $order Order being recalculated. 1132 * @return array|mixed The address, with `to_street` filled when it was safe to. 1133 */ 1134 private function fill_missing_backend_street( $address, $order ) { 1135 // Core verified the nonce and capability before woocommerce_before_save_order_items. 1136 if ( isset( $_POST['street'] ) || ! is_array( $address ) || ! empty( $address['to_street'] ) || ! $order instanceof WC_Order ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing 1137 return $address; 1138 } 1139 1140 $posted = Address::from_options( $address ); 1141 if ( '' === $posted->country() ) { 1142 return $address; 1143 } 1144 1145 // Core picks the side from the shipping country field as it stands in the form; 1146 // we only have the saved one. They differ if the merchant blanks or fills in that 1147 // field without saving, and when billing and shipping share a town the four posted 1148 // fields can't tell us which side they came from, so we can pair a billing 1149 // address with the shipping street. We still guess rather than send no street, 1150 // since a missing street is the bug this fallback exists to avoid. 1151 $type = ( 'shipping' === get_option( 'woocommerce_tax_based_on' ) && '' !== (string) $order->get_shipping_country() ) ? 'shipping' : 'billing'; 1152 $saved = Address::from_order( $order, $type ); 1153 1154 if ( '' === $saved->street() 1155 || $saved->country() !== $posted->country() 1156 || $saved->state() !== $posted->state() 1157 || $saved->postcode() !== $posted->postcode() 1158 || $saved->city() !== $posted->city() 1159 ) { 1160 $this->_log( 'Order items were saved without a street and the posted address does not match the saved ' . $type . ' address; calculating without a street.' ); 1161 return $address; 1162 } 1163 1164 $this->_log( 'Order items were saved without a street; using the saved ' . $type . ' street.' ); 1165 $address['to_street'] = $saved->street(); 1166 1167 return $address; 1168 } 1169 1170 /** 1171 * Read the TaxJar product tax code encoded in a tax class slug. 1172 * 1173 * Stores map a class to a TaxJar category by ending its name with the numeric 1174 * code, so "Digital Goods 31000" slugs to `digital-goods-31000`. The code is the 1175 * last segment, whatever the class name's word count. Both line item builders go 1176 * through here so checkout and an admin order recalculation price the same code. 1177 * 1178 * @param string $tax_class Tax class slug ('' for Standard). 1179 * 1180 * @return string The code, or '' if the slug does not end in a number. 1181 */ 1182 private static function get_product_tax_code_from_tax_class( $tax_class ) { 1183 $segments = explode( '-', (string) $tax_class ); 1184 $code = end( $segments ); 1185 1186 return is_numeric( $code ) ? $code : ''; 1187 } 1188 1189 /** 971 1190 * Get line items at checkout 972 1191 * … … 991 1210 $line_subtotal = wc_format_decimal( $cart_item['line_subtotal'] ); 992 1211 $discount = wc_format_decimal( $cart_item['line_subtotal'] - $cart_item['line_total'] ); 993 $tax_class = explode( '-', $product->get_tax_class() ); 994 $tax_code = ''; 995 996 if ( isset( $tax_class ) && is_numeric( end( $tax_class ) ) ) { 997 $tax_code = end( $tax_class ); 998 } 1212 $tax_code = self::get_product_tax_code_from_tax_class( $product->get_tax_class() ); 999 1213 1000 1214 if ( 'shipping' !== $product->get_tax_status() && ( ! $product->is_taxable() || 'zero-rate' == sanitize_title( $product->get_tax_class() ) ) ) { … … 1114 1328 } 1115 1329 $this->backend_tax_classes[ $id ] = $tax_class_name; 1116 $tax_class = explode( '-', $tax_class_name ); 1117 $tax_code = ''; 1118 if ( isset( $tax_class[1] ) && is_numeric( $tax_class[1] ) ) { 1119 $tax_code = $tax_class[1]; 1120 } 1330 $tax_code = self::get_product_tax_code_from_tax_class( $tax_class_name ); 1121 1331 if ( 'taxable' !== $tax_status ) { 1122 1332 $tax_code = '99999'; … … 1126 1336 // matching note in get_line_items() for why this is recorded, not derived. 1127 1337 $this->non_taxable_line_items[ $id . '-' . $item_key ] = true; 1338 } elseif ( 'zero-rate' === sanitize_title( $tax_class_name ) ) { 1339 // Checkout sends the built-in Zero rate class as exempt too, and does not 1340 // record it: the 0% TaxJar returns belongs in that class's rate row. 1341 $tax_code = '99999'; 1128 1342 } 1129 1343 … … 1449 1663 */ 1450 1664 public function override_order_item_taxes( $item, $calculate_tax_for ) { 1665 // An admin recalculation looked this item up in calculate_backend_totals(). 1666 $admin_rate_ids = $this->take_admin_recalculation_rate_ids( $item ); 1667 if ( null !== $admin_rate_ids ) { 1668 $this->set_order_item_taxes_from_rate_ids( $item, $admin_rate_ids ); 1669 return; 1670 } 1671 1451 1672 // Only act if we have TaxJar-calculated rate IDs from this request. 1452 1673 if ( empty( $this->response_rate_ids ) || ! is_array( $this->response_rate_ids ) ) { … … 1480 1701 } 1481 1702 1703 $this->set_order_item_taxes_from_rate_ids( $item, $matching_rate_ids ); 1704 } 1705 1706 /** 1707 * Take the rate ids an admin recalculation looked up for an order item. 1708 * 1709 * Each entry is removed as it is read, so it applies to the recalculation it was 1710 * looked up for and not to a later one in the same request. 1711 * 1712 * @param WC_Order_Item $item The order item being taxed. 1713 * @return int[]|null Rate ids, or null when the item was not looked up. 1714 */ 1715 private function take_admin_recalculation_rate_ids( $item ) { 1716 if ( ! ( $item instanceof \WC_Order_Item_Product ) ) { 1717 return null; 1718 } 1719 1720 $order_id = (int) $item->get_order_id(); 1721 $item_id = (int) $item->get_id(); 1722 1723 if ( ! isset( $this->admin_recalculation_rate_ids[ $order_id ][ $item_id ] ) ) { 1724 return null; 1725 } 1726 1727 $rate_ids = $this->admin_recalculation_rate_ids[ $order_id ][ $item_id ]; 1728 unset( $this->admin_recalculation_rate_ids[ $order_id ][ $item_id ] ); 1729 1730 return $rate_ids; 1731 } 1732 1733 /** 1734 * Tax an order item at the given rate rows, replacing what WooCommerce found. 1735 * 1736 * @param WC_Order_Item_Product $item The order item. 1737 * @param array $rate_ids Tax rate ids. 1738 */ 1739 private function set_order_item_taxes_from_rate_ids( $item, array $rate_ids ) { 1482 1740 // Build tax rates array from the stored rate IDs. 1483 1741 $tax_rates = array(); 1484 foreach ( $ matching_rate_ids as $rate_id ) {1742 foreach ( $rate_ids as $rate_id ) { 1485 1743 $rate_id = absint( $rate_id ); 1486 1744 if ( ! $rate_id ) { … … 1723 1981 1724 1982 if ( ! is_array( $address ) ) { 1725 $this->logger->error( 'Nexus Address ERRORS: Nexus addresses has invalid format' . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . print_r( $address, true), 'WCS Tax' );1983 $this->logger->error( 'Nexus Address ERRORS: Nexus addresses has invalid format' . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . wp_json_encode( $address, JSON_PARTIAL_OUTPUT_ON_ERROR ), 'WCS Tax' ); 1726 1984 1727 1985 return false; … … 1745 2003 1746 2004 if ( null !== $value && ( ! is_scalar( $value ) || is_bool( $value ) ) ) { 1747 // wp_json_encode() returns false for unencodable payloads, which is 1748 // what this branch catches; fall back so the log keeps the address. 1749 $encoded = wp_json_encode( $address ); 1750 $this->logger->error( 'Nexus Address ERRORS: [' . $field . '] field must be a string' . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . ( false === $encoded ? print_r( $address, true ) : $encoded ), 'WCS Tax' ); 2005 // This branch catches payloads wp_json_encode() cannot fully encode, so ask 2006 // for partial output to keep the rest of the address in the log. 2007 $this->logger->error( 'Nexus Address ERRORS: [' . $field . '] field must be a string' . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . wp_json_encode( $address, JSON_PARTIAL_OUTPUT_ON_ERROR ), 'WCS Tax' ); 1751 2008 1752 2009 return false; … … 1784 2041 $errors = str_replace( '[postcode]', '[zip]', $errors ); 1785 2042 1786 $this->logger->error( 'Nexus Address ERRORS: ' . implode( ', ', $errors ) . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . print_r( $address, true), 'WCS Tax' );2043 $this->logger->error( 'Nexus Address ERRORS: ' . implode( ', ', $errors ) . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . wp_json_encode( $address, JSON_PARTIAL_OUTPUT_ON_ERROR ), 'WCS Tax' ); 1787 2044 1788 2045 return false; … … 1828 2085 ( empty( $destination->postcode() ) && ! in_array( $destination->country(), WC()->countries->get_vat_countries(), true ) ) || 1829 2086 ( empty( $line_items ) && ( empty( $shipping_amount ) ) ) || 1830 WC()->customer->is_vat_exempt() 2087 // Orders recalculated from cron or WP-CLI run without a customer session. 2088 ( WC()->customer instanceof WC_Customer && WC()->customer->is_vat_exempt() ) 1831 2089 ) { 1832 2090 $this->_log( 'Destination address or cart data is incomplete, or the customer is VAT exempt. Aborting.' ); … … 2045 2303 ); 2046 2304 2305 // Priorities written per tax class, for shadow_unscoped_rates_above_components(). 2306 $written = array(); 2307 2047 2308 // Add line item tax rates. 2048 2309 foreach ( $taxes['line_items'] as $line_item_key => $line_item ) { … … 2073 2334 continue; 2074 2335 } 2336 $rate_name = self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions ); 2337 2075 2338 $taxes['rate_ids'][ $line_item_key ][] = $this->create_or_update_tax_rate( 2076 2339 $location, … … 2079 2342 $taxes['freight_taxable'], 2080 2343 $priority, 2081 self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions )2344 $rate_name 2082 2345 ); 2083 2346 2347 $this->record_written_priority( $written, $tax_class, $priority, $rate_name ); 2084 2348 ++$priority; 2085 2349 } … … 2093 2357 continue; 2094 2358 } 2359 $rate_name = self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions ); 2360 2095 2361 $taxes['rate_ids']['shipping'][] = $this->create_or_update_tax_rate( 2096 2362 $location, … … 2099 2365 $taxes['freight_taxable'], 2100 2366 $priority, 2101 self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions )2367 $rate_name 2102 2368 ); 2103 2369 2370 $this->record_written_priority( $written, '', $priority, $rate_name ); 2104 2371 ++$priority; 2372 } 2373 2374 foreach ( $written as $tax_class => $class_written ) { 2375 if ( ! $class_written['vat'] ) { 2376 $this->shadow_unscoped_rates_above_components( $location, (string) $tax_class, $class_written['priorities'], $jurisdictions ); 2377 } 2105 2378 } 2106 2379 } … … 2178 2451 * it is handed. 2179 2452 */ 2180 $field = static function ( $value ) { 2181 return is_scalar( $value ) ? (string) $value : ''; 2182 }; 2183 2184 $address = Address::from_options( 2185 array( 2186 'to_country' => $field( $location['to_country'] ?? '' ), 2187 // Prevent filling "State code" column for countries with VAT tax. 2188 // VAT tax is country wide. 2189 'to_state' => 'VAT' === $tax_rate_name ? '' : $field( $location['to_state'] ?? '' ), 2190 'to_zip' => $field( $location['to_zip'] ?? '' ), 2191 'to_city' => $field( $location['to_city'] ?? '' ), 2192 ) 2193 ); 2453 $address = $this->get_rate_table_address( $location, 'VAT' === $tax_rate_name ); 2194 2454 2195 2455 /** … … 2238 2498 } 2239 2499 2500 /* 2501 * Never repurpose a row whose scope is broader than the jurisdiction about to 2502 * be written. Rows this method inserts always carry the address's postcode or 2503 * city, so a matched row with no location rows at all is a merchant's 2504 * catch-all (or a row written for a scopeless address). Rewriting it in place 2505 * would turn it into, say, a 0% City component that still matches the whole 2506 * state. 2507 * 2508 * Inserting beside it is stable: `WC_Tax::find_rates()` keeps one row per 2509 * priority and prefers the one with more postcode/city locations, so the next 2510 * lookup for this address lands on the scoped row and reuses it, while the 2511 * catch-all keeps covering every other address. 2512 * 2513 * VAT rows are country-wide by design and addresses without a postcode or city 2514 * write no scope, so both keep reusing the matched row. Otherwise they would 2515 * insert a new row on every calculation. 2516 */ 2517 if ( ! empty( $wc_rate ) && 'VAT' !== $tax_rate_name ) { 2518 $wanted_locations = $address->to_rate_table_locations(); 2519 $wants_scope = '' !== $wanted_locations['postcode'] || '' !== $wanted_locations['city']; 2520 2521 if ( $wants_scope && ! $this->tax_rate_has_locations( key( $wc_rate ) ) ) { 2522 $this->_log( ':: Matched Tax Rate Is Not Location Scoped, Adding A Scoped Rate Beside It ::' ); 2523 $wc_rate = array(); 2524 } 2525 } 2526 2240 2527 if ( ! empty( $wc_rate ) ) { 2241 2528 $this->_log( ':: Tax Rate Found ::' ); … … 2270 2557 2271 2558 return $rate_id; 2559 } 2560 2561 /** 2562 * Whether a tax rate row is restricted to any postcode or city. 2563 * 2564 * @param int $rate_id Tax rate ID. 2565 * 2566 * @return bool 2567 */ 2568 private function tax_rate_has_locations( $rate_id ) { 2569 global $wpdb; 2570 2571 // WooCommerce has no API for one rate's locations: get_rates_for_tax_class() loads every 2572 // row in the class. Not cached: the row's locations change within the request that asks. 2573 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 2574 return (bool) $wpdb->get_var( 2575 $wpdb->prepare( 2576 "SELECT 1 FROM {$wpdb->prefix}woocommerce_tax_rate_locations WHERE tax_rate_id = %d LIMIT 1", 2577 $rate_id 2578 ) 2579 ); 2580 } 2581 2582 /** 2583 * Note that a component was written at a priority in a tax class. 2584 * 2585 * @param array $written Written priorities per tax class, updated in place. 2586 * @param string $tax_class Tax class. 2587 * @param int $priority Priority. 2588 * @param string $rate_name Row name; a VAT row marks the class as VAT. 2589 */ 2590 private function record_written_priority( array &$written, $tax_class, $priority, $rate_name ) { 2591 if ( ! isset( $written[ $tax_class ] ) ) { 2592 $written[ $tax_class ] = array( 2593 'priorities' => array(), 2594 'vat' => false, 2595 ); 2596 } 2597 2598 $written[ $tax_class ]['priorities'][] = (int) $priority; 2599 $written[ $tax_class ]['vat'] = $written[ $tax_class ]['vat'] || 'VAT' === $rate_name; 2600 } 2601 2602 /** 2603 * The address a rate row is written and looked up for. 2604 * 2605 * Non-scalars collapse to an empty string rather than reaching `(string)` and 2606 * becoming the literal "Array": create_or_update_tax_rate() is public and takes 2607 * the location it is handed. VAT rows are country-wide, so they carry no state. 2608 * 2609 * @param array $location Location in the `to_*` shape. 2610 * @param bool $is_vat Whether the row is a VAT row. 2611 * 2612 * @return Address 2613 */ 2614 private function get_rate_table_address( $location, $is_vat ) { 2615 $field = static function ( $value ) { 2616 return is_scalar( $value ) ? (string) $value : ''; 2617 }; 2618 2619 return Address::from_options( 2620 array( 2621 'to_country' => $field( $location['to_country'] ?? '' ), 2622 // Prevent filling "State code" column for countries with VAT tax. 2623 // VAT tax is country wide. 2624 'to_state' => $is_vat ? '' : $field( $location['to_state'] ?? '' ), 2625 'to_zip' => $field( $location['to_zip'] ?? '' ), 2626 'to_city' => $field( $location['to_city'] ?? '' ), 2627 ) 2628 ); 2629 } 2630 2631 /** 2632 * Stop a state-wide rate from adding to the rates just written for an address. 2633 * 2634 * `WC_Tax::find_rates()` keeps one row per priority. The components TaxJar 2635 * returned sit at priorities 1 to N, so a merchant's state-wide row at a higher 2636 * priority still matches the address and is added on top of them. Checkout and 2637 * an admin Recalculate apply only the rate ids TaxJar returned, but everything 2638 * else that reads the rate table (REST and POS orders, shipping) would charge both. 2639 * 2640 * For each such priority this adds a 0% row limited to the address's postcode and 2641 * city. It outranks the state-wide row at that priority, so the table gives the 2642 * address what TaxJar returned, and every other address still gets the merchant's 2643 * row. The merchant's row is not changed. 2644 * 2645 * The row is not marked for shipping, and it still keeps the merchant's row off 2646 * shipping: `WC_Tax::find_shipping_rates()` picks one row per priority first and 2647 * only then drops the rows not marked for shipping. Marked for shipping, it would 2648 * put a $0 tax line on every order that ships to the address, since checkout, 2649 * order creation and Recalculate all tax shipping from the table. 2650 * 2651 * Only rows with no postcode or city are covered; a merchant's own local rows are 2652 * left as they are. Nothing is added for VAT rows, which are country-wide, or for 2653 * an address with no postcode or city, since the new row could not outrank anything. 2654 * The next lookup for the address finds the 0% row at that priority instead of the 2655 * state-wide one, so nothing more is added. 2656 * 2657 * The rows are inserted directly rather than through create_or_update_tax_rate(), 2658 * which matches by position and could otherwise update a merchant's local row. 2659 * 2660 * @param array $location Location in the `to_*` shape, as written. 2661 * @param string $tax_class Tax class the components were written in. 2662 * @param int[] $priorities Priorities the components were written at. 2663 * @param array $jurisdictions County and city names, for the row name. 2664 */ 2665 private function shadow_unscoped_rates_above_components( $location, $tax_class, array $priorities, array $jurisdictions ) { 2666 $address = $this->get_rate_table_address( $location, false ); 2667 $locations = $address->to_rate_table_locations(); 2668 2669 if ( '' === $locations['postcode'] && '' === $locations['city'] ) { 2670 return; 2671 } 2672 2673 $matched = WC_Tax::find_rates( $address->to_find_rates_args( $tax_class ) ); 2674 2675 foreach ( array_keys( is_array( $matched ) ? $matched : array() ) as $rate_id ) { 2676 $row = WC_Tax::_get_tax_rate( $rate_id ); 2677 2678 if ( ! $row || in_array( (int) $row['tax_rate_priority'], $priorities, true ) || $this->tax_rate_has_locations( $rate_id ) ) { 2679 continue; 2680 } 2681 2682 $this->_log( ':: Adding A 0% Rate Beside A State-Wide Rate At Priority ' . (int) $row['tax_rate_priority'] . ' ::' ); 2683 2684 $shadow_id = WC_Tax::_insert_tax_rate( 2685 array( 2686 'tax_rate_country' => $address->country(), 2687 'tax_rate_state' => $address->state_compact(), 2688 'tax_rate_name' => self::generate_nullified_rate_name( $address->country(), $jurisdictions ), 2689 'tax_rate_priority' => (int) $row['tax_rate_priority'], 2690 'tax_rate_compound' => false, 2691 'tax_rate_shipping' => 0, 2692 'tax_rate' => 0, 2693 'tax_rate_class' => $tax_class, 2694 ) 2695 ); 2696 2697 WC_Tax::_update_tax_rate_postcodes( $shadow_id, $locations['postcode'] ); 2698 WC_Tax::_update_tax_rate_cities( $shadow_id, $locations['city'] ); 2699 } 2272 2700 } 2273 2701 … … 2371 2799 2372 2800 // Clear the taxjar notices before calculating taxes or using cached response. 2373 $this->notifier->clear_notices( 'taxjar' ); 2801 if ( $this->notifier ) { 2802 $this->notifier->clear_notices( 'taxjar' ); 2803 } 2374 2804 2375 2805 if ( false === $response ) { … … 2511 2941 2512 2942 // If HPOS is enabled, and we're on the Orders list page, return false. 2943 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: under HPOS the Orders list and the single order screen share one screen id, and only the single order screen carries WooCommerce's action arg. This just tells the two apart before deciding whether to enqueue assets. 2513 2944 if ( 'woocommerce_page_wc-orders' === $wc_order_screen_id && ! isset( $_GET['action'] ) ) { 2514 2945 return false; … … 2526 2957 } 2527 2958 // Load Javascript for WooCommerce new order page 2528 wp_enqueue_script( 'wc-taxjar-order', $this->wc_connect_base_url . 'woocommerce-services-new-order-taxjar-' . WC_Connect_Loader::get_wcs_version() . '.js', array( 'jquery' ), null, true ); 2959 wp_enqueue_script( 'wc-taxjar-order', $this->wc_connect_base_url . 'woocommerce-services-new-order-taxjar-' . WC_Connect_Loader::get_wcs_version() . '.js', array( 'jquery' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Served from the Connect Server and the version is already in the file name, so no ?ver= is wanted. 2529 2960 } 2530 2961 … … 2563 2994 2564 2995 if ( 'not set' === $to_state || 'not set' === $to_country || null === $has_nexus ) { 2565 throw new Exception( sprintf( 'One or more values are not set : to_state=>%1$s, to_country=>%2$s, has_nexus=>%3$s', $to_state, $to_country, json_encode( $has_nexus ) ) ); 2566 } 2996 throw new Exception( sprintf( 'One or more values are not set : to_state=>%1$s, to_country=>%2$s, has_nexus=>%3$s', esc_html( $to_state ), esc_html( $to_country ), esc_html( wp_json_encode( $has_nexus ) ) ) ); 2997 } 2998 } 2999 3000 /** 3001 * Whether an out-of-cart recalculation of this order should have its taxes preserved. 3002 * 3003 * Both halves of the preserve/restore pair consult this, because they hang off hooks 3004 * with different firing conditions: preserve_order_taxes_on_recalculation() runs from 3005 * calculate_taxes(), while restore_order_taxes_after_recalculation() runs from 3006 * calculate_totals() whether or not taxes were recalculated. A snapshot can therefore 3007 * reach the restore half without this having been evaluated on the way in. 3008 * 3009 * The gates are exclusionary: preservation runs for ANY out-of-cart recalculation of 3010 * an existing order — the REST/address-change path this fix targets, but also WP-CLI, 3011 * cron, and Action Scheduler runs. That breadth is intentional: once an order is 3012 * placed its tax is a record of what was charged, so we preserve it on every 3013 * programmatic recalculation, not only the REST path that prompted this fix. 3014 * 3015 * @since 3.7.1 3016 * 3017 * @param WC_Order $order The order being recalculated. 3018 * @return bool 3019 */ 3020 private function should_preserve_order_taxes( $order ) { 3021 // The cart/checkout flow populates response_rate_ids and manages its own taxes. 3022 if ( ! empty( $this->response_rate_ids ) ) { 3023 return false; 3024 } 3025 3026 // Admin order edits recalculate over AJAX and are handled by calculate_backend_totals(). 3027 if ( wp_doing_ajax() ) { 3028 return false; 3029 } 3030 3031 // A new order that does not exist yet has no recorded taxes to preserve. 3032 if ( ! $order->get_id() ) { 3033 return false; 3034 } 3035 3036 // WC zeroes the taxes of an exempt order; restoring them would undo the exemption. 3037 /** 3038 * Filters whether an order is VAT exempt. A WooCommerce core filter, applied here 3039 * with the same arguments WC_Abstract_Order::calculate_taxes() passes. 3040 * 3041 * @since 3.7.1 Applied by this plugin. 3042 * 3043 * @param bool $is_vat_exempt Whether the order is VAT exempt. 3044 * @param WC_Order $order The order being recalculated. 3045 */ 3046 if ( apply_filters( 'woocommerce_order_is_vat_exempt', 'yes' === $order->get_meta( 'is_vat_exempt' ), $order ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core filter. 3047 return false; 3048 } 3049 3050 return true; 3051 } 3052 3053 /** 3054 * Whether a shipping line with no tax shows that the order's shipping is not taxed. 3055 * 3056 * A free line or one whose method is not taxable carries no tax on any order, so it 3057 * says nothing about whether the address taxes shipping. 3058 * 3059 * @param WC_Order_Item_Shipping $item A shipping line. 3060 * @return bool 3061 */ 3062 private function shipping_line_could_carry_tax( $item ) { 3063 return 'taxable' === $item->get_tax_status() && 0.0 !== (float) $item->get_total(); 2567 3064 } 2568 3065 … … 2574 3071 * the checkout address, so when a REST API or programmatic update changes the 2575 3072 * address the lookup returns nothing and update_taxes() wipes every tax line to 2576 * zero. Once an order is placed its tax is a record of what was charged, so we 2577 * preserve it rather than recompute it: snapshot the existing taxes here (before 2578 * WC recalculates) and restore them once the totals have been recalculated. 2579 * 2580 * The cart/checkout and admin-AJAX flows have their own handling and are left 2581 * untouched, and an order with no existing tax lines is left to calculate for the 2582 * first time normally. 3073 * zero. So the rate table is never consulted here: snapshot the existing taxes 3074 * (before WC recalculates) and, once the totals have been recalculated, either 3075 * restore them or re-apply the order's own rates. 3076 * 3077 * The rate is fixed when the order is placed; the amount follows the order. If no 3078 * item, fee or shipping amount changed, the recorded tax is restored exactly (so a 3079 * plain re-save moves nothing, and a hand-typed figure survives it). If some did, 3080 * the rates recorded on the order's tax lines are re-applied to the items that 3081 * changed, and the order gets a note with the old and new tax. See 3082 * restore_order_taxes_after_recalculation(). 3083 * 3084 * Two cases ask TaxJar for fresh rates instead, once the totals are recalculated: 3085 * the address the order is taxed on changed (a stored rate belongs to the old 3086 * address), or the order has no tax yet and its items changed, as a new order's 3087 * have (there is no rate to re-use). See lookup_order_taxes(). 3088 * 3089 * The cart/checkout, Store API and admin-AJAX flows have their own handling and are 3090 * left untouched, an order with no existing tax lines and nothing to look up is left 3091 * to WC, and a VAT-exempt order keeps the zero tax WC gives it. 2583 3092 * 2584 3093 * Restoration happens on woocommerce_order_after_calculate_totals, the only post- … … 2599 3108 */ 2600 3109 public function preserve_order_taxes_on_recalculation( $args, $order ) { 2601 // The gates below are exclusionary: preservation runs for ANY out-of-cart 2602 // recalculation of an existing, already-taxed order — the REST/address-change 2603 // path this fix targets, but also WP-CLI, cron, and Action Scheduler runs. 2604 // That breadth is intentional: once an order is placed its tax is a record of 2605 // what was charged, so we preserve it on every programmatic recalculation, not 2606 // only the REST path that prompted this fix. 2607 2608 // The cart/checkout flow populates response_rate_ids and manages its own taxes. 2609 if ( ! empty( $this->response_rate_ids ) ) { 3110 // A snapshot left by an earlier calculate_taxes() must not survive a rejected one. 3111 // Kept otherwise: when WC's own lookup has already wiped the tax lines, the next 3112 // snapshot is empty and the earlier one is the only record of what was charged. 3113 if ( ! $this->should_preserve_order_taxes( $order ) ) { 3114 unset( $this->pre_recalculation_tax_snapshots[ (int) $order->get_id() ] ); 2610 3115 return; 2611 3116 } 2612 3117 2613 // Admin order edits recalculate over AJAX and are handled by calculate_backend_totals(). 2614 if ( wp_doing_ajax() ) { 3118 $snapshot = $this->snapshot_order_taxes( $order ); 3119 3120 // WC's own lookup in an earlier calculate_taxes() may have wiped the tax lines. 3121 // The earlier snapshot then still holds what was charged, and its own decision 3122 // about asking TaxJar; an empty one must not replace it. 3123 if ( empty( $snapshot['tax_lines'] ) && ! empty( $this->pre_recalculation_tax_snapshots[ (int) $order->get_id() ]['tax_lines'] ) ) { 2615 3124 return; 2616 3125 } 2617 3126 2618 // A new order that does not exist yet has no recorded taxes to preserve. 2619 if ( ! $order->get_id() ) { 2620 return; 2621 } 2622 2623 $snapshot = $this->snapshot_order_taxes( $order ); 2624 2625 // Only preserve when the order already had tax lines; never turn a first-time 2626 // tax calculation into a zeroed one. 2627 if ( empty( $snapshot['tax_lines'] ) ) { 3127 // What changed has to be read now: WC saves the order (and its items) while it 3128 // recalculates, which clears the pending changes this relies on. 3129 $snapshot['base_changes'] = $this->find_order_tax_base_changes( $order ); 3130 $snapshot['address_before'] = $this->find_order_tax_address_change( $order ); 3131 $snapshot['lookup'] = $this->order_needs_tax_lookup( $snapshot ); 3132 3133 // A refund records its tax against the order's rate ids. TaxJar's rates for a new 3134 // address are stored under other ids, so after a lookup the refunded tax would 3135 // belong to rates the order no longer has. Keep the recorded rates instead; the 3136 // admin does not let a refunded order be edited at all. 3137 $snapshot['kept_for_refunds'] = $snapshot['lookup'] && ! empty( $snapshot['tax_lines'] ) && 0.0 !== (float) $order->get_total_tax_refunded(); 3138 if ( $snapshot['kept_for_refunds'] ) { 3139 $snapshot['lookup'] = false; 3140 } 3141 3142 // Without tax lines there is nothing to preserve; never turn a first-time tax 3143 // calculation into a zeroed one. Leave it to WC unless TaxJar is to be asked. 3144 if ( empty( $snapshot['tax_lines'] ) && ! $snapshot['lookup'] ) { 2628 3145 return; 2629 3146 } … … 2641 3158 * 2642 3159 * Looks up the snapshot captured in preserve_order_taxes_on_recalculation() for the 2643 * recalculated order and, if one is present, restores it and drops it from the3160 * recalculated order and, if one is present, applies it and drops it from the 2644 3161 * pending set. Keyed by order id so a batch that recalculates several orders 2645 3162 * restores each from its own snapshot. 2646 3163 * 3164 * - Nothing taxable changed: the recorded tax is restored exactly. 3165 * - Something changed: the order's recorded rates are re-applied to the changed 3166 * items (see reapply_order_tax_rates()). 3167 * - Something changed but the order does not record its rates (orders from before 3168 * WC 3.7): the recorded tax is restored and a note says it was not updated. 3169 * - The taxed address changed, or a new order has no tax yet: TaxJar is asked and 3170 * its rates replace the order's tax. If that fails, the cases above apply as if 3171 * no lookup was due, and a note says the tax could not be updated. 3172 * - The taxed address changed on an order with refunded tax: TaxJar is not asked, 3173 * so the refunds keep matching the order's rates. The cases above apply, and a 3174 * note says why the tax was not updated. 3175 * 2647 3176 * @internal Hooked to woocommerce_order_after_calculate_totals. 2648 3177 * … … 2660 3189 unset( $this->pre_recalculation_tax_snapshots[ $order_id ] ); 2661 3190 2662 $this->restore_order_taxes( $order, $snapshot ); 3191 // This hook fires from calculate_totals() whether or not taxes were recalculated, 3192 // so a snapshot can arrive here without the gates having run. Re-check them: an 3193 // order made VAT exempt since the snapshot was taken must not get its tax back. 3194 if ( ! $this->should_preserve_order_taxes( $order ) ) { 3195 return; 3196 } 3197 3198 $lookup = $snapshot['lookup'] ? $this->lookup_order_taxes( $order ) : null; 3199 $reapplied = false; 3200 3201 if ( is_array( $lookup ) ) { 3202 $this->apply_looked_up_order_taxes( $order, $lookup, $snapshot ); 3203 } elseif ( empty( $snapshot['tax_lines'] ) ) { 3204 // Nothing to keep: what WC calculated stands, as it did before the lookup existed. 3205 if ( self::ORDER_TAX_LOOKUP_BAD_ADDRESS === $lookup ) { 3206 $this->add_order_tax_note( $order, __( 'Tax could not be calculated for this order, because its address is incomplete or has a state or ZIP code that is not valid. Check the order\'s address and its tax.', 'woocommerce-services' ) ); 3207 } elseif ( self::ORDER_TAX_LOOKUP_BAD_STORE_ADDRESS === $lookup ) { 3208 $this->add_order_tax_note( $order, __( 'Tax could not be calculated for this order, because the store address has no country or has a ZIP code that is not valid. Check the store address in WooCommerce > Settings > General, then the tax on this order.', 'woocommerce-services' ) ); 3209 } elseif ( false === $lookup ) { 3210 $this->add_order_tax_note( $order, __( 'Tax could not be calculated for this order, because the tax service did not answer or did not accept the order\'s address. Check the order\'s address and its tax.', 'woocommerce-services' ) ); 3211 } 3212 } elseif ( empty( $snapshot['base_changes']['has_changes'] ) ) { 3213 $this->restore_order_taxes( $order, $snapshot ); 3214 } elseif ( ! $this->snapshot_records_tax_rates( $snapshot ) ) { 3215 $this->restore_order_taxes( $order, $snapshot ); 3216 $order->add_order_note( __( 'Tax was not updated after this order changed, because the order does not record the tax rates it was placed with. Check the tax on this order.', 'woocommerce-services' ) ); 3217 } else { 3218 $this->reapply_order_tax_rates( $order, $snapshot ); 3219 $reapplied = true; 3220 } 3221 3222 if ( ! is_array( $lookup ) && null !== $lookup && ! empty( $snapshot['tax_lines'] ) ) { 3223 $this->add_order_tax_note( $order, $this->get_failed_order_tax_update_note( $lookup, $reapplied ) ); 3224 } 3225 3226 if ( ! empty( $snapshot['kept_for_refunds'] ) ) { 3227 $this->add_order_tax_note( 3228 $order, 3229 $reapplied 3230 ? __( 'Tax was not updated for the new address, because this order has refunded tax. The tax rates recorded when the order was placed were used instead. Check the order\'s address and its tax.', 'woocommerce-services' ) 3231 : __( 'Tax was not updated for the new address, because this order has refunded tax. The tax this order already had was kept. Check the order\'s address and its tax.', 'woocommerce-services' ) 3232 ); 3233 } 3234 3235 // The old amounts and address have been used; a later recalculation of the same 3236 // order in this request must compare against what is saved now. 3237 foreach ( $order->get_items( array( 'line_item', 'fee', 'shipping' ) ) as $item_id => $item ) { 3238 unset( $this->order_item_base_before_save[ $item_id ], $this->order_items_created_in_request['ids'][ $item_id ], $this->order_item_tax_locations[ spl_object_id( $item ) ] ); 3239 } 3240 unset( $this->order_address_before_save[ $order_id ], $this->orders_created_in_request['ids'][ $order_id ] ); 3241 } 3242 3243 /** 3244 * Remember an order item's taxable amount before a save changes it. 3245 * 3246 * The REST API saves an edited item before it recalculates the order, so by the 3247 * time preserve_order_taxes_on_recalculation() runs, the item no longer shows what 3248 * it was. Only the first save in a request is kept: that is the amount the order's 3249 * recorded tax was based on. 3250 * 3251 * @internal Hooked to woocommerce_before_order_item_object_save. 3252 * 3253 * @param WC_Order_Item $item The item about to be saved. 3254 * 3255 * @since 3.7.1 3256 */ 3257 public function remember_order_item_base_before_save( $item ) { 3258 if ( ! $item instanceof WC_Order_Item || ! in_array( $item->get_type(), array( 'line_item', 'fee', 'shipping' ), true ) ) { 3259 return; 3260 } 3261 3262 $item_id = (int) $item->get_id(); 3263 3264 if ( ! $item_id ) { 3265 $this->order_items_created_in_request['objects'][ spl_object_id( $item ) ] = true; 3266 return; 3267 } 3268 3269 if ( isset( $this->order_item_base_before_save[ $item_id ] ) ) { 3270 return; 3271 } 3272 3273 $changes = $item->get_changes(); 3274 3275 if ( ! array_key_exists( 'total', $changes ) && ! array_key_exists( 'subtotal', $changes ) ) { 3276 return; 3277 } 3278 3279 // get_data() still holds the saved values until the save applies the changes. 3280 $this->order_item_base_before_save[ $item_id ] = self::get_order_item_tax_base( $item->get_data() ); 3281 } 3282 3283 /** 3284 * Remember the location WooCommerce taxed an order item for. 3285 * 3286 * @internal Hooked to woocommerce_order_item_after_calculate_taxes and its fee and 3287 * shipping counterparts, at the earliest priority. 3288 * 3289 * @param WC_Order_Item $item The item WooCommerce just taxed. 3290 * @param mixed $calculate_tax_for The location it was taxed for. 3291 * 3292 * @since 3.7.1 3293 */ 3294 public function remember_order_item_tax_location( $item, $calculate_tax_for = null ) { 3295 if ( ! $item instanceof WC_Order_Item || ! is_array( $calculate_tax_for ) || ! isset( $this->pre_recalculation_tax_snapshots[ (int) $item->get_order_id() ] ) ) { 3296 return; 3297 } 3298 3299 $this->order_item_tax_locations[ spl_object_id( $item ) ] = $calculate_tax_for; 3300 } 3301 3302 /** 3303 * Run an item's after-calculate-taxes hook again, on the tax set for it here. 3304 * 3305 * WooCommerce fires the hook once it has taxed an item, and other plugins adjust the 3306 * tax there (an exemption plugin zeroes it). The tax set here replaces WooCommerce's, 3307 * so the hook runs again with the same arguments, and those plugins adjust this tax 3308 * as they adjusted WooCommerce's. Nothing runs for an item WooCommerce did not tax in 3309 * this recalculation. 3310 * 3311 * @param WC_Order_Item $item An item whose tax was just set. 3312 */ 3313 private function run_order_item_tax_hook( $item ) { 3314 $key = spl_object_id( $item ); 3315 $hooks = self::ORDER_ITEM_TAX_HOOKS; 3316 if ( ! isset( $this->order_item_tax_locations[ $key ], $hooks[ $item->get_type() ] ) ) { 3317 return; 3318 } 3319 3320 /** 3321 * Fires after an order item's taxes are calculated. WooCommerce core's 3322 * woocommerce_order_item_after_calculate_taxes, or its fee or shipping 3323 * counterpart, run again on the tax this plugin set. 3324 * 3325 * @since 3.7.1 Run again by this plugin. 3326 * 3327 * @param WC_Order_Item $item The order item. 3328 * @param array $calculate_tax_for The location the item was taxed for. 3329 */ 3330 do_action( $hooks[ $item->get_type() ], $item, $this->order_item_tax_locations[ $key ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- WooCommerce core hooks, see ORDER_ITEM_TAX_HOOKS. 3331 } 3332 3333 /** 3334 * Record the id an order item was given when it was first saved. 3335 * 3336 * @internal Hooked to woocommerce_after_order_item_object_save. 3337 * 3338 * @param WC_Order_Item $item The saved item. 3339 * 3340 * @since 3.7.1 3341 */ 3342 public function remember_order_item_created( $item ) { 3343 if ( ! $item instanceof WC_Order_Item ) { 3344 return; 3345 } 3346 3347 $object_id = spl_object_id( $item ); 3348 3349 if ( isset( $this->order_items_created_in_request['objects'][ $object_id ] ) ) { 3350 unset( $this->order_items_created_in_request['objects'][ $object_id ] ); 3351 $this->order_items_created_in_request['ids'][ (int) $item->get_id() ] = true; 3352 } 2663 3353 } 2664 3354 … … 2773 3463 $order->save(); 2774 3464 } 3465 3466 /** 3467 * Work out which of an order's taxable amounts changed since it was last saved. 3468 * 3469 * Covers the three ways an edit reaches a recalculation: an existing item whose 3470 * total changed (saved already, or still pending), an item added (no id yet, or 3471 * first saved in this request), and an item removed (still saved, but no longer on 3472 * the order). 3473 * 3474 * @param WC_Order $order The order about to be recalculated. 3475 * @return array { 3476 * @type int[] $known_ids Items saved before this request, still on the order. 3477 * @type int[] $changed_ids Saved items whose taxable amount changed. 3478 * @type array $new_item_taxes Taxes of new items that arrived already taxed, keyed by spl_object_id(). 3479 * @type array $removed_rate_ids Rate ids of removed items: 'classes' keyed by tax class, and 'shipping'; 3480 * 'untaxed_shipping' is true when a removed shipping line carried no tax. 3481 * @type bool $has_changes Whether anything taxable changed, including additions and removals. 3482 * } 3483 */ 3484 private function find_order_tax_base_changes( $order ) { 3485 $types = array( 'line_item', 'fee', 'shipping' ); 3486 $known_ids = array(); 3487 $changed_ids = array(); 3488 $new_item_taxes = array(); 3489 $added = false; 3490 3491 foreach ( $order->get_items( $types ) as $item ) { 3492 $item_id = (int) $item->get_id(); 3493 $is_new = ! $item_id || isset( $this->order_items_created_in_request['ids'][ $item_id ] ); 3494 3495 if ( $is_new ) { 3496 $added = true; 3497 3498 // Its tax was set by whoever added it (the Store API copies the cart's), 3499 // so it was taxed at its own sale. Keyed by object: it may have no id yet. 3500 $taxes = $item->get_taxes(); 3501 if ( ! empty( $taxes['total'] ) ) { 3502 $new_item_taxes[ spl_object_id( $item ) ] = $taxes; 3503 } 3504 continue; 3505 } 3506 3507 $known_ids[] = $item_id; 3508 3509 if ( $this->order_item_base_moved( $item ) ) { 3510 $changed_ids[] = $item_id; 3511 } 3512 } 3513 3514 $saved_items = array(); 3515 foreach ( $types as $type ) { 3516 $saved_items += (array) $order->get_data_store()->read_items( $order, $type ); 3517 } 3518 $removed = array_diff( array_map( 'intval', array_keys( $saved_items ) ), $known_ids, array_keys( $this->order_items_created_in_request['ids'] ) ); 3519 3520 // The rates removed items were taxed at, for a new item that replaces the last 3521 // one of its tax class. They have to be read now: the recalculation deletes them. 3522 $removed_rate_ids = array( 3523 'classes' => array(), 3524 'shipping' => array(), 3525 'untaxed_shipping' => false, 3526 ); 3527 foreach ( $removed as $item_id ) { 3528 // read_items() gives false for an item whose class cannot be loaded. 3529 if ( ! $saved_items[ $item_id ] instanceof WC_Order_Item ) { 3530 continue; 3531 } 3532 3533 $taxes = $saved_items[ $item_id ]->get_taxes(); 3534 $rate_ids = empty( $taxes['total'] ) ? array() : array_keys( $taxes['total'] ); 3535 3536 if ( 'shipping' === $saved_items[ $item_id ]->get_type() ) { 3537 $removed_rate_ids['shipping'] = array_unique( array_merge( $removed_rate_ids['shipping'], $rate_ids ) ); 3538 $removed_rate_ids['untaxed_shipping'] = $removed_rate_ids['untaxed_shipping'] || ( ! $rate_ids && $this->shipping_line_could_carry_tax( $saved_items[ $item_id ] ) ); 3539 } elseif ( $rate_ids ) { 3540 $tax_class = $saved_items[ $item_id ]->get_tax_class(); 3541 $removed_rate_ids['classes'][ $tax_class ] = array_unique( array_merge( $removed_rate_ids['classes'][ $tax_class ] ?? array(), $rate_ids ) ); 3542 } 3543 } 3544 3545 return array( 3546 'known_ids' => $known_ids, 3547 'changed_ids' => $changed_ids, 3548 'new_item_taxes' => $new_item_taxes, 3549 'removed_rate_ids' => $removed_rate_ids, 3550 'has_changes' => $added || ! empty( $removed ) || ! empty( $changed_ids ), 3551 ); 3552 } 3553 3554 /** 3555 * Whether a saved order item's taxable amount differs from what it was taxed on. 3556 * 3557 * Compared as numbers: a caller that re-sends "10.00" for a stored "10" has not 3558 * changed anything. 3559 * 3560 * @param WC_Order_Item $item A saved line item, fee or shipping item. 3561 * @return bool 3562 */ 3563 private function order_item_base_moved( $item ) { 3564 $item_id = (int) $item->get_id(); 3565 3566 if ( isset( $this->order_item_base_before_save[ $item_id ] ) ) { 3567 $before = $this->order_item_base_before_save[ $item_id ]; 3568 } else { 3569 $changes = $item->get_changes(); 3570 3571 if ( ! array_key_exists( 'total', $changes ) && ! array_key_exists( 'subtotal', $changes ) ) { 3572 return false; 3573 } 3574 3575 $before = self::get_order_item_tax_base( $item->get_data() ); 3576 } 3577 3578 $after = self::get_order_item_tax_base( 3579 array( 3580 'total' => $item->get_total(), 3581 'subtotal' => is_callable( array( $item, 'get_subtotal' ) ) ? $item->get_subtotal() : null, 3582 ) 3583 ); 3584 3585 foreach ( array( 'total', 'subtotal' ) as $key ) { 3586 if ( abs( (float) $before[ $key ] - (float) $after[ $key ] ) > 0.000001 ) { 3587 return true; 3588 } 3589 } 3590 3591 return false; 3592 } 3593 3594 /** 3595 * The amounts an order item's tax is calculated on. 3596 * 3597 * @param array $data Item data (WC_Order_Item::get_data() shape). 3598 * @return array{total: float|null, subtotal: float|null} 3599 */ 3600 private static function get_order_item_tax_base( $data ) { 3601 return array( 3602 'total' => isset( $data['total'] ) ? (float) $data['total'] : null, 3603 'subtotal' => isset( $data['subtotal'] ) ? (float) $data['subtotal'] : null, 3604 ); 3605 } 3606 3607 /** 3608 * Whether every tax line in a snapshot records the rate it was charged at. 3609 * 3610 * Orders from before WC 3.7 have no rate_percent, and WC reads the missing value 3611 * back as 0. A 0% line that carries tax therefore means "rate unknown", and 3612 * re-applying 0% to it would wipe real tax. 3613 * 3614 * @param array $snapshot Snapshot returned by snapshot_order_taxes(). 3615 * @return bool 3616 */ 3617 private function snapshot_records_tax_rates( $snapshot ) { 3618 foreach ( $snapshot['tax_lines'] as $tax_line ) { 3619 if ( 0.0 === (float) $tax_line['rate_percent'] && ( 0.0 !== (float) $tax_line['tax_total'] || 0.0 !== (float) $tax_line['shipping_tax_total'] ) ) { 3620 return false; 3621 } 3622 } 3623 3624 return true; 3625 } 3626 3627 /** 3628 * Re-apply an order's recorded rates to the items whose amounts changed. 3629 * 3630 * Items that did not change keep the tax they had. A changed item is taxed at the 3631 * rates it already carries; a new item or fee at the rates of an existing item in 3632 * the same tax class; a new shipping line at the rates of the existing shipping. 3633 * If the edit removed the last item of that class (or all the shipping), the rates 3634 * the removed items were taxed at are used. With no such rate on the order, a new 3635 * item is left untaxed and the note says so, unless it is shipping on an order 3636 * whose shipping was not taxed either. 3637 * A new item that arrives with its tax already set keeps it. 3638 * Percentages, labels and codes come from the order's tax lines, never from the 3639 * rate table, which may have changed since the order was placed. 3640 * 3641 * @param WC_Order $order The recalculated order. 3642 * @param array $snapshot Snapshot taken before the recalculation. 3643 */ 3644 private function reapply_order_tax_rates( $order, $snapshot ) { 3645 $tax_lines_by_rate = array(); 3646 $rates = array(); 3647 foreach ( $snapshot['tax_lines'] as $tax_line ) { 3648 $rate_id = (int) $tax_line['rate_id']; 3649 $tax_lines_by_rate[ $rate_id ] = $tax_line; 3650 $rates[ $rate_id ] = array( 3651 'rate' => (float) $tax_line['rate_percent'], 3652 'label' => $tax_line['label'], 3653 'shipping' => 'yes', 3654 'compound' => $tax_line['compound'] ? 'yes' : 'no', 3655 ); 3656 } 3657 3658 $known_ids = $snapshot['base_changes']['known_ids']; 3659 $changed_ids = $snapshot['base_changes']['changed_ids']; 3660 3661 // Rates already on the order, per tax class and for shipping, for new items. 3662 $rate_ids_by_class = array(); 3663 $shipping_rate_ids = array(); 3664 $untaxed_shipping = $snapshot['base_changes']['removed_rate_ids']['untaxed_shipping']; 3665 foreach ( $order->get_items( array( 'line_item', 'fee', 'shipping' ) ) as $item_id => $item ) { 3666 $key = 'shipping' === $item->get_type() ? 'shipping_' . $item_id : $item_id; 3667 if ( ! in_array( (int) $item_id, $known_ids, true ) ) { 3668 continue; 3669 } 3670 3671 if ( empty( $snapshot['item_taxes'][ $key ]['total'] ) ) { 3672 // Shipping the order did not tax: a new shipping line at no tax is expected. 3673 $untaxed_shipping = $untaxed_shipping || ( 'shipping' === $item->get_type() && $this->shipping_line_could_carry_tax( $item ) ); 3674 continue; 3675 } 3676 3677 $item_rate_ids = array_keys( $snapshot['item_taxes'][ $key ]['total'] ); 3678 if ( 'shipping' === $item->get_type() ) { 3679 $shipping_rate_ids = array_unique( array_merge( $shipping_rate_ids, $item_rate_ids ) ); 3680 } else { 3681 $tax_class = $item->get_tax_class(); 3682 $rate_ids_by_class[ $tax_class ] = array_unique( array_merge( $rate_ids_by_class[ $tax_class ] ?? array(), $item_rate_ids ) ); 3683 } 3684 } 3685 3686 // When the edit removed the last item of a class (or all the shipping), a new one 3687 // takes the rates the removed ones were taxed at. 3688 $rate_ids_by_class += $snapshot['base_changes']['removed_rate_ids']['classes']; 3689 if ( empty( $shipping_rate_ids ) ) { 3690 $shipping_rate_ids = $snapshot['base_changes']['removed_rate_ids']['shipping']; 3691 } 3692 3693 $untaxed = array(); 3694 foreach ( $order->get_items( array( 'line_item', 'fee', 'shipping' ) ) as $item_id => $item ) { 3695 $is_shipping = 'shipping' === $item->get_type(); 3696 $key = $is_shipping ? 'shipping_' . $item_id : $item_id; 3697 $is_known = in_array( (int) $item_id, $known_ids, true ); 3698 3699 if ( $is_known && ! in_array( (int) $item_id, $changed_ids, true ) ) { 3700 if ( isset( $snapshot['item_taxes'][ $key ] ) ) { 3701 $item->set_taxes( $snapshot['item_taxes'][ $key ] ); 3702 } 3703 continue; 3704 } 3705 3706 if ( ! $is_known && isset( $snapshot['base_changes']['new_item_taxes'][ spl_object_id( $item ) ] ) ) { 3707 $item->set_taxes( $snapshot['base_changes']['new_item_taxes'][ spl_object_id( $item ) ] ); 3708 continue; 3709 } 3710 3711 if ( 'taxable' !== $item->get_tax_status() ) { 3712 $item_rate_ids = array(); 3713 } elseif ( $is_known ) { 3714 $item_rate_ids = empty( $snapshot['item_taxes'][ $key ]['total'] ) ? array() : array_keys( $snapshot['item_taxes'][ $key ]['total'] ); 3715 } elseif ( $is_shipping ) { 3716 $item_rate_ids = $shipping_rate_ids; 3717 } else { 3718 $item_rate_ids = $rate_ids_by_class[ $item->get_tax_class() ] ?? array(); 3719 } 3720 3721 $item_rates = array_intersect_key( $rates, array_flip( array_map( 'intval', $item_rate_ids ) ) ); 3722 3723 if ( empty( $item_rates ) ) { 3724 if ( ! $is_known && 'taxable' === $item->get_tax_status() && ! ( $is_shipping && $untaxed_shipping ) ) { 3725 $untaxed[] = $item->get_name(); 3726 } 3727 $item->set_taxes( false ); 3728 $this->run_order_item_tax_hook( $item ); 3729 continue; 3730 } 3731 3732 $taxes = array( 'total' => WC_Tax::calc_tax( $item->get_total(), $item_rates, false ) ); 3733 if ( is_callable( array( $item, 'get_subtotal' ) ) ) { 3734 $taxes['subtotal'] = WC_Tax::calc_tax( $item->get_subtotal(), $item_rates, false ); 3735 } 3736 $item->set_taxes( $taxes ); 3737 $this->run_order_item_tax_hook( $item ); 3738 } 3739 3740 // Put back what the order recorded about each rate: update_taxes() reads it 3741 // from the rate table. 3742 $recorded = array(); 3743 foreach ( $tax_lines_by_rate as $rate_id => $tax_line ) { 3744 $recorded[ $rate_id ] = array_intersect_key( $tax_line, array_flip( array( 'rate_code', 'label', 'rate_percent', 'compound' ) ) ); 3745 } 3746 $this->rebuild_order_tax_totals( $order, $recorded ); 3747 3748 $old_tax = (float) $snapshot['cart_tax'] + (float) $snapshot['shipping_tax']; 3749 $new_tax = (float) $order->get_cart_tax() + (float) $order->get_shipping_tax(); 3750 $notes = array(); 3751 3752 if ( abs( $old_tax - $new_tax ) > 0.000001 ) { 3753 $notes[] = sprintf( 3754 /* translators: 1: tax before the order changed, 2: tax after. */ 3755 __( 'Tax updated because the order changed: %1$s → %2$s. The tax rates recorded when the order was placed were used.', 'woocommerce-services' ), 3756 wc_price( $old_tax, array( 'currency' => $order->get_currency() ) ), 3757 wc_price( $new_tax, array( 'currency' => $order->get_currency() ) ) 3758 ); 3759 } 3760 3761 if ( $untaxed ) { 3762 $notes[] = sprintf( 3763 /* translators: %s: comma-separated names of order items. */ 3764 __( 'No tax was added for %s, because the order has no recorded tax rate for it. Check the tax on this order.', 'woocommerce-services' ), 3765 implode( ', ', array_map( 'wp_strip_all_tags', $untaxed ) ) 3766 ); 3767 } 3768 3769 if ( $notes ) { 3770 $this->add_order_tax_note( $order, implode( ' ', $notes ) ); 3771 } 3772 } 3773 3774 /** 3775 * Rebuild an order's tax lines and totals from the taxes now set on its items. 3776 * 3777 * WC has already recomputed the non-tax amounts by the time this runs; they are 3778 * kept and only the tax is swapped. 3779 * 3780 * @param WC_Order $order The recalculated order. 3781 * @param array $recorded Tax line fields to set per rate id after update_taxes() 3782 * (any of rate_code, label, rate_percent, compound). 3783 */ 3784 private function rebuild_order_tax_totals( $order, array $recorded ) { 3785 $non_tax_total = (float) $order->get_total() - (float) $order->get_cart_tax() - (float) $order->get_shipping_tax(); 3786 3787 $order->update_taxes(); 3788 foreach ( $order->get_taxes() as $tax_item ) { 3789 $rate_id = (int) $tax_item->get_rate_id(); 3790 foreach ( $recorded[ $rate_id ] ?? array() as $field => $value ) { 3791 $tax_item->{'set_' . $field}( $value ); 3792 } 3793 } 3794 3795 // Same sum WC_Abstract_Order::calculate_totals() uses for the discount tax. 3796 $subtotal_tax = 0.0; 3797 $total_tax = 0.0; 3798 foreach ( $order->get_items() as $item ) { 3799 $taxes = $item->get_taxes(); 3800 $total_tax += array_sum( array_map( 'floatval', $taxes['total'] ) ); 3801 $subtotal_tax += array_sum( array_map( 'floatval', $taxes['subtotal'] ) ); 3802 } 3803 $order->set_discount_tax( wc_round_tax_total( $subtotal_tax - $total_tax ) ); 3804 $order->set_total( round( $non_tax_total + (float) $order->get_cart_tax() + (float) $order->get_shipping_tax(), wc_get_price_decimals() ) ); 3805 $order->save(); 3806 } 3807 3808 /** 3809 * Add a note about the order's tax. 3810 * 3811 * A checkout draft is still being built from the cart; a note there would outlive 3812 * the checkout and describe changes the merchant never made. 3813 * 3814 * @param WC_Order $order The order. 3815 * @param string $note Note text. 3816 */ 3817 private function add_order_tax_note( $order, $note ) { 3818 if ( 'checkout-draft' !== $order->get_status() ) { 3819 $order->add_order_note( $note ); 3820 } 3821 } 3822 3823 /** 3824 * The note for an address change whose lookup failed on an order that had tax. 3825 * 3826 * Says why the lookup failed and what was done instead: the order's tax was kept, 3827 * or, when other changes were made in the same edit, the recorded rates were 3828 * re-applied to them. 3829 * 3830 * @param string|false $lookup Failed result of lookup_order_taxes(). 3831 * @param bool $reapplied Whether the recorded rates were re-applied. 3832 * @return string 3833 */ 3834 private function get_failed_order_tax_update_note( $lookup, $reapplied ) { 3835 if ( self::ORDER_TAX_LOOKUP_BAD_ADDRESS === $lookup ) { 3836 return $reapplied 3837 ? __( 'Tax could not be updated for the new address, because it is incomplete or has a state or ZIP code that is not valid. The tax rates recorded when the order was placed were used instead. Check the order\'s address and its tax.', 'woocommerce-services' ) 3838 : __( 'Tax could not be updated for the new address, because it is incomplete or has a state or ZIP code that is not valid. The tax this order already had was kept. Check the order\'s address and its tax.', 'woocommerce-services' ); 3839 } 3840 3841 if ( self::ORDER_TAX_LOOKUP_BAD_STORE_ADDRESS === $lookup ) { 3842 return $reapplied 3843 ? __( 'Tax could not be updated for the new address, because the store address has no country or has a ZIP code that is not valid. The tax rates recorded when the order was placed were used instead. Check the store address in WooCommerce > Settings > General, then the tax on this order.', 'woocommerce-services' ) 3844 : __( 'Tax could not be updated for the new address, because the store address has no country or has a ZIP code that is not valid. The tax this order already had was kept. Check the store address in WooCommerce > Settings > General, then the tax on this order.', 'woocommerce-services' ); 3845 } 3846 3847 return $reapplied 3848 ? __( 'Tax could not be updated for the new address, because the tax service did not answer or did not accept the address. The tax rates recorded when the order was placed were used instead. Check the order\'s address and its tax.', 'woocommerce-services' ) 3849 : __( 'Tax could not be updated for the new address, because the tax service did not answer or did not accept the address. The tax this order already had was kept. Check the order\'s address and its tax.', 'woocommerce-services' ); 3850 } 3851 3852 /** 3853 * Remember an order's address before a save changes it, and which orders are new. 3854 * 3855 * Only the first save in a request is kept: that is the address the order's 3856 * recorded tax was calculated for. 3857 * 3858 * @internal Hooked to woocommerce_before_order_object_save. 3859 * 3860 * @param WC_Order $order The order about to be saved. 3861 * 3862 * @since 3.7.1 3863 */ 3864 public function remember_order_before_save( $order ) { 3865 if ( ! $order instanceof WC_Order ) { 3866 return; 3867 } 3868 3869 $order_id = (int) $order->get_id(); 3870 3871 if ( ! $order_id ) { 3872 $this->orders_created_in_request['objects'][ spl_object_id( $order ) ] = true; 3873 return; 3874 } 3875 3876 // An order created in this request had no address its tax was based on. 3877 if ( isset( $this->order_address_before_save[ $order_id ] ) || isset( $this->orders_created_in_request['ids'][ $order_id ] ) ) { 3878 return; 3879 } 3880 3881 $changes = $order->get_changes(); 3882 3883 if ( empty( $changes['billing'] ) && empty( $changes['shipping'] ) ) { 3884 return; 3885 } 3886 3887 // get_data() still holds the saved values until the save applies the changes. 3888 $data = $order->get_data(); 3889 3890 $this->order_address_before_save[ $order_id ] = array( 3891 'billing' => $data['billing'], 3892 'shipping' => $data['shipping'], 3893 ); 3894 } 3895 3896 /** 3897 * Record the id a new order was given when it was first saved. 3898 * 3899 * @internal Hooked to woocommerce_after_order_object_save. 3900 * 3901 * @param WC_Order $order The saved order. 3902 * 3903 * @since 3.7.1 3904 */ 3905 public function remember_order_created( $order ) { 3906 if ( ! $order instanceof WC_Order ) { 3907 return; 3908 } 3909 3910 $object_id = spl_object_id( $order ); 3911 3912 if ( isset( $this->orders_created_in_request['objects'][ $object_id ] ) ) { 3913 unset( $this->orders_created_in_request['objects'][ $object_id ] ); 3914 $this->orders_created_in_request['ids'][ (int) $order->get_id() ] = true; 3915 } 3916 } 3917 3918 /** 3919 * The address an order was taxed on before this request changed it. 3920 * 3921 * Compares the address WC would tax the order on (see get_order_tax_address()) 3922 * before and after, street included: two addresses in the same ZIP can have 3923 * different rates. 3924 * 3925 * @param WC_Order $order The order about to be recalculated. 3926 * @return Address|null The old address, or null if it did not change. 3927 */ 3928 private function find_order_tax_address_change( $order ) { 3929 $order_id = (int) $order->get_id(); 3930 3931 if ( isset( $this->order_address_before_save[ $order_id ] ) ) { 3932 $before = $this->order_address_before_save[ $order_id ]; 3933 unset( $this->order_address_before_save[ $order_id ] ); 3934 } else { 3935 $data = $order->get_data(); 3936 $before = array( 3937 'billing' => $data['billing'], 3938 'shipping' => $data['shipping'], 3939 ); 3940 } 3941 3942 $location_type = $this->get_order_tax_location_type( $order ); 3943 $old_address = $this->get_order_tax_address( $before['billing'], $before['shipping'], $location_type ); 3944 $new_address = $this->get_order_tax_address( $order->get_address( 'billing' ), $order->get_address( 'shipping' ), $location_type ); 3945 3946 return $old_address->to_taxable_tuple() === $new_address->to_taxable_tuple() ? null : $old_address; 3947 } 3948 3949 /** 3950 * Whether TaxJar should be asked for this order's rates once it is recalculated. 3951 * 3952 * Asked when the address the order is taxed on changed, or when the order has no 3953 * tax yet and its items changed. A new order counts: its items are first saved in 3954 * the same request. Everything else re-uses the rates recorded on the order, with 3955 * no request. 3956 * 3957 * @param array $snapshot Snapshot with base_changes and address_before. 3958 * @return bool 3959 */ 3960 private function order_needs_tax_lookup( array $snapshot ) { 3961 // The Store API is the cart's own flow. 3962 if ( WC_Connect_Functions::is_store_api_call() ) { 3963 return false; 3964 } 3965 3966 if ( null !== $snapshot['address_before'] ) { 3967 return true; 3968 } 3969 3970 if ( ! empty( $snapshot['tax_lines'] ) ) { 3971 return false; 3972 } 3973 3974 return ! empty( $snapshot['base_changes']['has_changes'] ); 3975 } 3976 3977 /** 3978 * Which address an order is taxed on: 'base', 'billing' or 'shipping'. 3979 * 3980 * Mirrors WC_Abstract_Order::get_tax_location(): the Tax "Calculate tax based on" 3981 * setting, and the store address when the order uses local pickup. 3982 * 3983 * @param WC_Order $order The order. 3984 * @return string 3985 */ 3986 private function get_order_tax_location_type( $order ) { 3987 /** 3988 * Filters whether to apply base tax for local pickup. A WooCommerce core filter, 3989 * applied here as WC_Abstract_Order::get_tax_location() applies it. 3990 * 3991 * @since 3.7.1 Applied by this plugin. 3992 * 3993 * @param bool $apply_base_tax Whether to apply base tax for local pickup. 3994 */ 3995 if ( true === apply_filters( 'woocommerce_apply_base_tax_for_local_pickup', true ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core filter. 3996 /** 3997 * Filters the local pickup shipping method ids. A WooCommerce core filter. 3998 * 3999 * @since 3.7.1 Applied by this plugin. 4000 * 4001 * @param string[] $local_pickup_methods Local pickup shipping method ids. 4002 */ 4003 $local_pickup_methods = (array) apply_filters( 'woocommerce_local_pickup_methods', array( 'legacy_local_pickup', 'local_pickup' ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core filter. 4004 4005 foreach ( $order->get_shipping_methods() as $shipping ) { 4006 if ( in_array( $shipping->get_method_id(), $local_pickup_methods, true ) ) { 4007 return 'base'; 4008 } 4009 } 4010 } 4011 4012 return (string) get_option( 'woocommerce_tax_based_on', 'shipping' ); 4013 } 4014 4015 /** 4016 * The address an order is taxed on, built from its billing and shipping fields. 4017 * 4018 * Mirrors WC_Abstract_Order::get_tax_location(): shipping falls back to billing when 4019 * the order has no shipping country, and the store address is used when the 4020 * location type is 'base' or the chosen address has no country. 4021 * 4022 * @param array $billing Billing fields (WC_Order::get_address() shape). 4023 * @param array $shipping Shipping fields (WC_Order::get_address() shape). 4024 * @param string $location_type 'base', 'billing' or 'shipping'. 4025 * @return Address 4026 */ 4027 private function get_order_tax_address( array $billing, array $shipping, $location_type ) { 4028 if ( 'shipping' === $location_type && empty( $shipping['country'] ) ) { 4029 $location_type = 'billing'; 4030 } 4031 4032 $fields = 'billing' === $location_type ? $billing : $shipping; 4033 4034 if ( 'base' === $location_type || empty( $fields['country'] ) ) { 4035 return Address::from_store_settings( $this->get_store_settings() ); 4036 } 4037 4038 return Address::from_options( 4039 array( 4040 'to_country' => $fields['country'] ?? '', 4041 'to_state' => $fields['state'] ?? '', 4042 'to_zip' => $fields['postcode'] ?? '', 4043 'to_city' => $fields['city'] ?? '', 4044 'to_street' => $fields['address_1'] ?? '', 4045 ) 4046 ); 4047 } 4048 4049 /** 4050 * Ask TaxJar for the rates of an order at its current address. 4051 * 4052 * Built like the admin Recalculate request (same line items), with the address taken 4053 * from the order instead of the form. Items whose tax location is filtered to a 4054 * different address are asked for separately, as at checkout. Answers are cached 4055 * for an hour (see smartcalcs_cache_request()). 4056 * 4057 * @param WC_Order $order The recalculated order. 4058 * @return array|string|false|null Lookup result; ORDER_TAX_LOOKUP_BAD_ADDRESS when the 4059 * order address has a US state WooCommerce does not 4060 * list, or failed as one TaxJar cannot be asked 4061 * about; ORDER_TAX_LOOKUP_BAD_STORE_ADDRESS when it 4062 * failed on the store address; false when any other 4063 * request failed; null when the order has nothing 4064 * to ask about. 4065 */ 4066 private function lookup_order_taxes( $order ) { 4067 $default_type = $this->get_order_tax_location_type( $order ); 4068 $line_items = $this->get_backend_line_items( $order ); 4069 $shipping = (float) $order->get_shipping_total(); 4070 4071 $groups = array(); 4072 foreach ( $line_items as $item_key => $line_item ) { 4073 $type = 'base' === $default_type ? 'base' : ( $line_item['tax_location'] ?? $default_type ); 4074 $groups[ $type ][ $item_key ] = $line_item; 4075 } 4076 if ( $shipping > 0 && ! isset( $groups[ $default_type ] ) ) { 4077 $groups[ $default_type ] = array(); 4078 } 4079 4080 if ( ! $groups ) { 4081 return null; 4082 } 4083 4084 $billing = $order->get_address( 'billing' ); 4085 $result = array( 4086 'line_items' => $line_items, 4087 'rate_ids' => array(), 4088 'response_lines' => array(), 4089 'address' => $this->with_state_code( $this->get_order_tax_address( $billing, $order->get_address( 'shipping' ), $default_type ) ), 4090 ); 4091 4092 foreach ( $groups as $type => $items ) { 4093 $address = $this->with_state_code( $this->get_order_tax_address( $billing, $order->get_address( 'shipping' ), $type ) ); 4094 4095 // calculate_tax() would take it for another state's address and answer "no 4096 // tax" without asking. Keep the order's tax instead. 4097 if ( $this->has_unknown_us_state( $address ) ) { 4098 return self::ORDER_TAX_LOOKUP_BAD_ADDRESS; 4099 } 4100 4101 $taxes = $this->calculate_tax( 4102 array_merge( 4103 $address->to_legacy_options(), 4104 array( 4105 'shipping_amount' => $type === $default_type ? $shipping : 0, 4106 'line_items' => $items, 4107 ) 4108 ) 4109 ); 4110 4111 // calculate_tax() answers false for a failed request and for an address it 4112 // refused to send alike. Tell them apart here, after the fact, so what it 4113 // does with every other address stays exactly as at checkout. 4114 if ( false === $taxes ) { 4115 if ( ! $this->is_sendable_order_tax_address( $address ) ) { 4116 return self::ORDER_TAX_LOOKUP_BAD_ADDRESS; 4117 } 4118 4119 return $this->is_sendable_store_tax_address() ? false : self::ORDER_TAX_LOOKUP_BAD_STORE_ADDRESS; 4120 } 4121 4122 // Keys are canonical line item ids, unique across groups, plus 'shipping', 4123 // which only the default group sends. 4124 $result['rate_ids'] += $taxes['rate_ids']; 4125 $result['response_lines'] += $taxes['line_items']; 4126 } 4127 4128 return $result; 4129 } 4130 4131 /** 4132 * The address with a state name replaced by the state's code. 4133 * 4134 * The REST API accepts "ISO code or name" for the state and saves it as sent, so an 4135 * order can carry "Michigan" where checkout would send "MI". Matched case-insensitively 4136 * against WooCommerce's state names, as the Store API's ValidationUtils::format_state() 4137 * does. Only the address sent to TaxJar changes; the order keeps what it was given. 4138 * 4139 * @param Address $address The address the order is taxed at. 4140 * @return Address 4141 */ 4142 private function with_state_code( Address $address ) { 4143 $states = WC()->countries->get_states( $address->country() ); 4144 4145 if ( ! is_array( $states ) || '' === $address->state() || isset( $states[ $address->state() ] ) ) { 4146 return $address; 4147 } 4148 4149 $codes = array_flip( array_map( 'wc_strtoupper', $states ) ); 4150 $state = wc_strtoupper( $address->state() ); 4151 4152 if ( ! isset( $codes[ $state ] ) ) { 4153 return $address; 4154 } 4155 4156 return Address::from_options( array_merge( $address->to_legacy_options(), array( 'to_state' => (string) $codes[ $state ] ) ) ); 4157 } 4158 4159 /** 4160 * Whether a US address has no state, or one WooCommerce does not list. 4161 * 4162 * Such an address would lose its tax without a request: calculate_tax() compares the 4163 * order's state with the store's and answers "no tax" for any mismatch. 4164 * 4165 * @param Address $address The address the order is taxed at, after with_state_code(). 4166 * @return bool 4167 */ 4168 private function has_unknown_us_state( Address $address ) { 4169 if ( 'US' !== $address->country() ) { 4170 return false; 4171 } 4172 4173 $states = WC()->countries->get_states( 'US' ); 4174 4175 return is_array( $states ) && $states && ! isset( $states[ $address->state() ] ); 4176 } 4177 4178 /** 4179 * Whether an order's tax address has what TaxJar needs to be asked about it. 4180 * 4181 * Mirrors the destination checks that calculate_tax() and validate_taxjar_request() 4182 * make before sending anything: a country, a postcode outside the VAT countries, a 4183 * state in the US and Canada, and a well-formed US ZIP code when it is 5 or 10 4184 * characters long. Kept no stricter than those, so an address that was sent is 4185 * never blamed for a request that failed. 4186 * 4187 * @param Address $address The address the order is taxed at. 4188 * @return bool 4189 */ 4190 private function is_sendable_order_tax_address( Address $address ) { 4191 $country = $address->country(); 4192 $postcode = $address->postcode(); 4193 4194 if ( '' === $country ) { 4195 return false; 4196 } 4197 4198 if ( '' === $postcode && ! in_array( $country, WC()->countries->get_vat_countries(), true ) ) { 4199 return false; 4200 } 4201 4202 if ( in_array( $country, array( 'US', 'CA' ), true ) && '' === $address->state() ) { 4203 return false; 4204 } 4205 4206 return 'US' !== $country || ! in_array( strlen( $postcode ), array( 5, 10 ), true ) || WC_Validation::is_postcode( $postcode, 'US' ); 4207 } 4208 4209 /** 4210 * Whether the store address has what TaxJar needs to be asked from it. 4211 * 4212 * Mirrors the origin checks that calculate_tax() and validate_taxjar_request() make 4213 * before sending anything: a country, and a valid ZIP code for a US store. Reads the 4214 * store settings, not a nexus address a filter may send in their place. 4215 * 4216 * @return bool 4217 */ 4218 private function is_sendable_store_tax_address() { 4219 $store = Address::from_store_settings( $this->get_store_settings() ); 4220 4221 if ( '' === $store->country() ) { 4222 return false; 4223 } 4224 4225 return 'US' !== $store->country() || WC_Validation::is_postcode( $store->postcode(), 'US' ); 4226 } 4227 4228 /** 4229 * Tax an order at the rates TaxJar just returned for it. 4230 * 4231 * Every line is re-taxed: the old rates belonged to the old address, or there were 4232 * none. A fee is taxed at the rates of a product in its tax class, or keeps the tax 4233 * WC gave it when no such product was looked up. The address and 4234 * the change are recorded in a note when the address changed or the tax moved. 4235 * 4236 * An answer without rates means the store has no nexus at the address. The tax WC 4237 * calculated from the rate table is kept then, as checkout and the admin Recalculate 4238 * keep it, so a rate the merchant added for that state is still charged. 4239 * 4240 * @param WC_Order $order The recalculated order. 4241 * @param array $lookup Result of lookup_order_taxes(). 4242 * @param array $snapshot Snapshot taken before the recalculation. 4243 */ 4244 private function apply_looked_up_order_taxes( $order, array $lookup, array $snapshot ) { 4245 if ( empty( $lookup['rate_ids'] ) ) { 4246 if ( null !== $snapshot['address_before'] ) { 4247 $this->add_order_tax_note( 4248 $order, 4249 sprintf( 4250 /* translators: 1: address the tax was calculated for, 2: tax before, 3: tax after. */ 4251 __( 'Tax updated for %1$s from the store\'s tax rates: %2$s → %3$s.', 'woocommerce-services' ), 4252 $this->format_order_tax_address( $lookup['address'] ), 4253 wc_price( (float) $snapshot['cart_tax'] + (float) $snapshot['shipping_tax'], array( 'currency' => $order->get_currency() ) ), 4254 wc_price( (float) $order->get_cart_tax() + (float) $order->get_shipping_tax(), array( 'currency' => $order->get_currency() ) ) 4255 ) 4256 ); 4257 } 4258 return; 4259 } 4260 4261 $line_rates = array(); 4262 $rates_by_class = array(); 4263 foreach ( $order->get_items() as $item_key => $item ) { 4264 $line_id = $lookup['line_items'][ $item_key ]['id'] ?? null; 4265 if ( null === $line_id || empty( $lookup['rate_ids'][ $line_id ] ) ) { 4266 continue; 4267 } 4268 4269 $line_rates[ $item_key ] = $this->get_looked_up_rates( $lookup['rate_ids'][ $line_id ], $lookup['response_lines'][ $line_id ] ?? null ); 4270 4271 if ( ! isset( $rates_by_class[ $item->get_tax_class() ] ) ) { 4272 $rates_by_class[ $item->get_tax_class() ] = $line_rates[ $item_key ]; 4273 } 4274 } 4275 $shipping_rates = empty( $lookup['rate_ids']['shipping'] ) ? array() : $this->get_looked_up_rates( $lookup['rate_ids']['shipping'] ); 4276 4277 $percents = array(); 4278 $untaxed = array(); 4279 foreach ( $order->get_items( array( 'line_item', 'fee', 'shipping' ) ) as $item_key => $item ) { 4280 if ( 'taxable' !== $item->get_tax_status() ) { 4281 $item->set_taxes( false ); 4282 $this->run_order_item_tax_hook( $item ); 4283 continue; 4284 } 4285 4286 if ( 'shipping' === $item->get_type() ) { 4287 $rates = $shipping_rates; 4288 } elseif ( 'fee' === $item->get_type() ) { 4289 $rates = $rates_by_class[ $item->get_tax_class() ] ?? array(); 4290 4291 // No product in the fee's tax class was looked up: keep the tax WC gave it 4292 // from the rate table, as checkout and the admin Recalculate do for fees. 4293 if ( ! $rates ) { 4294 $fee_taxes = $item->get_taxes(); 4295 if ( empty( $fee_taxes['total'] ) || ! array_filter( $fee_taxes['total'] ) ) { 4296 $untaxed[] = $item->get_name(); 4297 } 4298 continue; 4299 } 4300 } else { 4301 $rates = $line_rates[ $item_key ] ?? array(); 4302 } 4303 4304 if ( ! $rates ) { 4305 $item->set_taxes( false ); 4306 $this->run_order_item_tax_hook( $item ); 4307 continue; 4308 } 4309 4310 $taxes = array( 'total' => WC_Tax::calc_tax( $item->get_total(), $rates, false ) ); 4311 if ( is_callable( array( $item, 'get_subtotal' ) ) ) { 4312 $taxes['subtotal'] = WC_Tax::calc_tax( $item->get_subtotal(), $rates, false ); 4313 } 4314 $item->set_taxes( $taxes ); 4315 $this->run_order_item_tax_hook( $item ); 4316 4317 foreach ( $rates as $rate_id => $rate ) { 4318 $percents[ $rate_id ] = array( 'rate_percent' => $rate['rate'] ); 4319 } 4320 } 4321 4322 // The rate table holds one row per rate and tax class, so two products taxed at 4323 // different rates share a row; the tax line shows the rate TaxJar returned. 4324 $this->rebuild_order_tax_totals( $order, $percents ); 4325 4326 if ( $untaxed ) { 4327 $this->add_order_tax_note( 4328 $order, 4329 sprintf( 4330 /* translators: %s: comma-separated names of order items. */ 4331 __( 'No tax was added for %s, because the order has no recorded tax rate for it. Check the tax on this order.', 'woocommerce-services' ), 4332 implode( ', ', array_map( 'wp_strip_all_tags', $untaxed ) ) 4333 ) 4334 ); 4335 } 4336 4337 $old_tax = (float) $snapshot['cart_tax'] + (float) $snapshot['shipping_tax']; 4338 $new_tax = (float) $order->get_cart_tax() + (float) $order->get_shipping_tax(); 4339 4340 if ( null === $snapshot['address_before'] && ( empty( $snapshot['tax_lines'] ) || abs( $old_tax - $new_tax ) < 0.000001 ) ) { 4341 return; 4342 } 4343 4344 $this->add_order_tax_note( 4345 $order, 4346 sprintf( 4347 /* translators: 1: address the tax was calculated for, 2: tax before, 3: tax after. */ 4348 __( 'Tax recalculated with today\'s rates for %1$s: %2$s → %3$s.', 'woocommerce-services' ), 4349 $this->format_order_tax_address( $lookup['address'] ), 4350 wc_price( $old_tax, array( 'currency' => $order->get_currency() ) ), 4351 wc_price( $new_tax, array( 'currency' => $order->get_currency() ) ) 4352 ) 4353 ); 4354 } 4355 4356 /** 4357 * An order's tax address on one line, for an order note. 4358 * 4359 * @param Address $address The address the order is taxed at. 4360 * @return string 4361 */ 4362 private function format_order_tax_address( Address $address ) { 4363 return implode( ', ', array_filter( array( $address->street(), $address->city(), $address->state(), $address->postcode(), $address->country() ) ) ); 4364 } 4365 4366 /** 4367 * Rates for WC_Tax::calc_tax() from rate ids TaxJar's answer was stored under. 4368 * 4369 * The percentage comes from the answer when it is given: it is what TaxJar charged 4370 * this line, while the shared row may since hold another line's rate. 4371 * 4372 * @param array $rate_ids Rate ids, in the order the answer's rates were stored. 4373 * @param object|null $response_line The answer's breakdown line for this item. 4374 * @return array 4375 */ 4376 private function get_looked_up_rates( array $rate_ids, $response_line = null ) { 4377 $percents = array(); 4378 if ( is_object( $response_line ) ) { 4379 // Same filter get_itemized_tax_rates() applies when it stores them. 4380 foreach ( (array) $response_line as $name => $value ) { 4381 if ( 'combined_tax_rate' === $name || false === strpos( $name, '_tax_rate' ) ) { 4382 continue; 4383 } 4384 $percents[] = round( (float) $value * 100, 4 ); 4385 } 4386 } 4387 4388 $rates = array(); 4389 foreach ( array_values( $rate_ids ) as $index => $rate_id ) { 4390 $row = WC_Tax::_get_tax_rate( absint( $rate_id ) ); 4391 if ( ! $row ) { 4392 continue; 4393 } 4394 $rates[ absint( $rate_id ) ] = array( 4395 'rate' => $percents[ $index ] ?? (float) $row['tax_rate'], 4396 'label' => $row['tax_rate_name'], 4397 'shipping' => 'yes', 4398 'compound' => ! empty( $row['tax_rate_compound'] ) ? 'yes' : 'no', 4399 ); 4400 } 4401 4402 return $rates; 4403 } 2775 4404 } -
woocommerce-services/tags/3.7.1/classes/class-wc-connect-tracks.php
r3452871 r3732427 94 94 } 95 95 96 $data['_via_ua'] = isset( $_SERVER['HTTP_USER_AGENT'] ) ? wc_clean( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';97 $data['_via_ip'] = isset( $_SERVER['REMOTE_ADDR'] ) ? wc_clean( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';98 $data['_lg'] = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? wc_clean( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '';96 $data['_via_ua'] = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; 97 $data['_via_ip'] = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; 98 $data['_lg'] = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : ''; 99 99 $data['blog_url'] = get_option( 'siteurl' ); 100 100 $data['blog_id'] = $jetpack_blog_id; … … 116 116 } 117 117 } 118 119 118 } 120 119 -
woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-account-settings-controller.php
r2498257 r3732427 58 58 'save_failed', 59 59 sprintf( 60 /* translators: %s: error message */ 60 61 __( 'Unable to update settings. %s', 'woocommerce-services' ), 61 62 $result->get_error_message() -
woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-base-controller.php
r2556587 r3732427 153 153 } 154 154 155 /** 156 * Get the order a label route acts on, before anything is written or sent upstream. 157 * 158 * Anything that is not an order, such as a page, a product or a refund, is not found. 159 * 160 * @param int|string $order_id Order ID from the route. 161 * 162 * @return WC_Order|WP_Error 163 */ 164 protected function get_order_for_label_request( $order_id ) { 165 $order_id = (int) $order_id; 166 $order = $order_id > 0 ? wc_get_order( $order_id ) : false; 167 168 if ( ! $order instanceof WC_Order ) { 169 return new WP_Error( 'not_found', __( 'Order not found', 'woocommerce-services' ), array( 'status' => 404 ) ); 170 } 171 172 return $order; 173 } 174 175 /** 176 * Whether a shipping label is one of the order's own labels. 177 * 178 * @param WC_Order $order Order from get_order_for_label_request(). 179 * @param int|string $label_id Label ID from the route. 180 * 181 * @return bool 182 */ 183 protected function is_label_on_order( WC_Order $order, $label_id ) { 184 foreach ( $this->settings_store->get_label_order_meta_data( $order->get_id() ) as $label ) { 185 if ( is_array( $label ) && isset( $label['label_id'] ) && (int) $label['label_id'] === (int) $label_id ) { 186 return true; 187 } 188 } 189 190 return false; 191 } 155 192 } -
woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-packages-controller.php
r2498257 r3732427 65 65 if ( ! empty( $custom_packages ) ) { 66 66 // Validate that the new custom packages have unique names. 67 $map_package_name = function ( $package ) {67 $map_package_name = function ( $package ) { 68 68 return $package['name']; 69 69 }; … … 75 75 $error = array( 76 76 'code' => 'duplicate_custom_package_names', 77 'message' => __( 'The new custom package names are not unique.' ),77 'message' => __( 'The new custom package names are not unique.', 'woocommerce-services' ), 78 78 'data' => array( 'package_names' => array_values( $duplicate_package_names ) ), 79 79 ); … … 89 89 $error = array( 90 90 'code' => 'duplicate_custom_package_names_of_existing_packages', 91 'message' => __( 'At least one of the new custom packages has the same name as existing packages.' ),91 'message' => __( 'At least one of the new custom packages has the same name as existing packages.', 'woocommerce-services' ), 92 92 'data' => array( 'package_names' => array_values( $duplicate_package_names ) ), 93 93 ); … … 116 116 $error = array( 117 117 'code' => 'duplicate_predefined_package_names', 118 'message' => __( 'The new predefined package names are not unique.' ),118 'message' => __( 'The new predefined package names are not unique.', 'woocommerce-services' ), 119 119 'data' => array( 'package_names_by_carrier' => $duplicate_package_names_by_carrier ), 120 120 ); … … 137 137 $error = array( 138 138 'code' => 'duplicate_predefined_package_names_of_existing_packages', 139 'message' => __( 'At least one of the new predefined packages has the same name as existing packages.' ),139 'message' => __( 'At least one of the new predefined packages has the same name as existing packages.', 'woocommerce-services' ), 140 140 'data' => array( 'package_names_by_carrier' => $duplicate_package_names_by_carrier ), 141 141 ); -
woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-services-controller.php
r2498257 r3732427 90 90 'validation_failed', 91 91 sprintf( 92 /* translators: %s: validation error message */ 92 93 __( 'Unable to update service settings. Validation failed. %s', 'woocommerce-services' ), 93 94 $validation_result->get_error_message() … … 101 102 return new WP_REST_Response( array( 'success' => true ), 200 ); 102 103 } 103 104 104 } -
woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-shipping-label-controller.php
r3298671 r3732427 43 43 44 44 public function post( $request ) { 45 // Check the order before a label is bought for it. 46 $order = $this->get_order_for_label_request( $request['order_id'] ); 47 if ( is_wp_error( $order ) ) { 48 return $order; 49 } 50 51 // The Connect server has always received the order ID as a string. 45 52 $settings = $request->get_json_params(); 46 $order_id = $request['order_id'];53 $order_id = (string) $order->get_id(); 47 54 $settings['order_id'] = $order_id; 48 55 … … 147 154 $product_names[] = $product->get_title(); 148 155 } else { 149 $order = wc_get_order( $order_id );150 156 $product_names[] = WC_Connect_Utils::get_product_name_from_order( $product_id, $order ); 151 157 } … … 158 164 } 159 165 160 $this->settings_store->add_labels_to_order( $order _id, $purchased_labels_meta );166 $this->settings_store->add_labels_to_order( $order->get_id(), $purchased_labels_meta ); 161 167 162 168 return array( -
woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-shipping-label-refund-controller.php
r2498257 r3732427 13 13 14 14 public function post( $request ) { 15 $order = $this->get_order_for_label_request( $request['order_id'] ); 16 if ( is_wp_error( $order ) ) { 17 return $order; 18 } 19 20 // Never refund a label that is not this order's. 21 if ( ! $this->is_label_on_order( $order, $request['label_id'] ) ) { 22 return new WP_Error( 'not_found', __( 'Shipping label not found', 'woocommerce-services' ), array( 'status' => 404 ) ); 23 } 24 15 25 $response = $this->api_client->send_shipping_label_refund_request( $request['label_id'] ); 16 26 … … 38 48 'refund' => $response->refund, 39 49 ); 40 $this->settings_store->update_label_order_meta_data( $ request['order_id'], $label_refund );50 $this->settings_store->update_label_order_meta_data( $order->get_id(), $label_refund ); 41 51 42 52 return array( -
woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-shipping-label-status-controller.php
r2510126 r3732427 13 13 14 14 public function get( $request ) { 15 $order = $this->get_order_for_label_request( $request['order_id'] ); 16 if ( is_wp_error( $order ) ) { 17 return $order; 18 } 19 15 20 $label_ids = explode( ',', $request['label_ids'] ); 16 $labels = array(); 21 22 // Every label must be this order's before any status is fetched. 23 foreach ( $label_ids as $label_id ) { 24 if ( ! $this->is_label_on_order( $order, $label_id ) ) { 25 return new WP_Error( 'not_found', __( 'Shipping label not found', 'woocommerce-services' ), array( 'status' => 404 ) ); 26 } 27 } 28 29 $labels = array(); 17 30 foreach ( $label_ids as $label_id ) { 18 31 $response = $this->api_client->get_label_status( $label_id ); … … 27 40 } 28 41 29 $label = $this->settings_store->update_label_order_meta_data( $ request['order_id'], $response->label );42 $label = $this->settings_store->update_label_order_meta_data( $order->get_id(), $response->label ); 30 43 $labels[] = $label; 31 44 } -
woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-shipping-rates-controller.php
r2498257 r3732427 34 34 35 35 /** 36 * Whether an address from the request is an array with a street, so saving it cannot blank 37 * the stored origin or the order's shipping street. 38 * 39 * @param mixed $address Address from the request. 40 * 41 * @return bool 42 */ 43 private function has_street_address( $address ) { 44 return is_array( $address ) && isset( $address['address'] ) && is_string( $address['address'] ) && '' !== trim( $address['address'] ); 45 } 46 47 /** 48 * Whether an ID is a product on one of the order's line items, as product or variation. 49 * 50 * @param WC_Order $order Order the rates are for. 51 * @param string $product_id Product ID from the request. 52 * 53 * @return bool 54 */ 55 private function is_product_on_order( WC_Order $order, $product_id ) { 56 if ( ! ctype_digit( $product_id ) || 0 === (int) $product_id || ! wc_get_product( (int) $product_id ) ) { 57 return false; 58 } 59 60 return false !== WC_Connect_Utils::get_line_item_from_order( (int) $product_id, $order ); 61 } 62 63 /** 36 64 * 37 65 * @param WP_REST_Request $request - See WC_Connect_API_Client::get_label_rates() … … 39 67 */ 40 68 public function post( $request ) { 41 $payload = $request->get_json_params(); 69 $order = $this->get_order_for_label_request( $request['order_id'] ); 70 if ( is_wp_error( $order ) ) { 71 return $order; 72 } 73 74 $payload = $request->get_json_params(); 75 76 // Check the body before anything is saved, so a bad request changes nothing. 77 if ( ! is_array( $payload ) 78 || ! isset( $payload['origin'], $payload['destination'], $payload['packages'] ) 79 || ! $this->has_street_address( $payload['origin'] ) 80 || ! $this->has_street_address( $payload['destination'] ) 81 || ! is_array( $payload['packages'] ) 82 || count( array_filter( $payload['packages'], 'is_array' ) ) !== count( $payload['packages'] ) ) { 83 return new WP_Error( 'bad_request', __( 'Bad request', 'woocommerce-services' ), array( 'status' => 400 ) ); 84 } 85 42 86 $payload['payment_method_id'] = $this->settings_store->get_selected_payment_method_id(); 43 $order_id = $ request['order_id'];87 $order_id = $order->get_id(); 44 88 45 89 // This is the earliest point in the printing label flow where we are sure that … … 55 99 } 56 100 foreach ( $package['items'] as $index => $item ) { 57 if ( ! isset( $updated_product_ids[ $item['product_id'] ] ) ) { 58 $updated_product_ids[ $item['product_id'] ] = true; 101 $product_id = isset( $item['product_id'] ) && is_scalar( $item['product_id'] ) ? (string) $item['product_id'] : ''; 102 103 if ( ! isset( $updated_product_ids[ $product_id ] ) ) { 104 $updated_product_ids[ $product_id ] = true; 105 106 // Only products on this order get customs info; skip any other ID. 107 if ( ! $this->is_product_on_order( $order, $product_id ) ) { 108 $this->logger->log( 109 sprintf( 'Skipped customs info for ID %s: not a product on order %d.', wp_json_encode( $product_id ), $order_id ), 110 __CLASS__ 111 ); 112 continue; 113 } 114 59 115 update_post_meta( 60 $item['product_id'],116 (int) $product_id, 61 117 'wc_connect_customs_info', 62 118 array( -
woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-tos-controller.php
r2498257 r3732427 42 42 43 43 /** 44 * Validate the requester's permissions 44 * Validate the requester's permissions: the capabilities below, and only a user who 45 * may accept the Terms of Service (the connection owner, or anyone in offline mode). 46 * 47 * @param WP_REST_Request $request Request being authorized. 48 * @return bool 45 49 */ 46 50 public function check_permission( $request ) { 47 51 return current_user_can( 'manage_woocommerce' ) && 48 52 current_user_can( 'install_plugins' ) && 49 current_user_can( 'activate_plugins' ); 53 current_user_can( 'activate_plugins' ) && 54 class_exists( 'WC_Connect_Nux' ) && 55 WC_Connect_Nux::current_user_can_accept_tos(); 50 56 } 51 57 } -
woocommerce-services/tags/3.7.1/classes/wc-api-dev/class-wc-rest-dev-data-continents-controller.php
r3034437 r3732427 76 76 'args' => array( 77 77 'continent' => array( 78 'description' => __( '2 character continent code.', 'woocommerce ' ),78 'description' => __( '2 character continent code.', 'woocommerce-services' ), 79 79 'type' => 'string', 80 80 ), … … 116 116 $data = $this->continents->get_continent( strtoupper( $request['location'] ), $request ); 117 117 if ( empty( $data ) ) { 118 return new WP_Error( 'woocommerce_rest_data_invalid_location', __( 'There are no locations matching these parameters.', 'woocommerce ' ), array( 'status' => 404 ) );118 return new WP_Error( 'woocommerce_rest_data_invalid_location', __( 'There are no locations matching these parameters.', 'woocommerce-services' ), array( 'status' => 404 ) ); 119 119 } 120 120 return $this->prepare_item_for_response( $data, $request ); … … 181 181 'code' => array( 182 182 'type' => 'string', 183 'description' => __( '2 character continent code.', 'woocommerce ' ),183 'description' => __( '2 character continent code.', 'woocommerce-services' ), 184 184 'context' => array( 'view' ), 185 185 'readonly' => true, … … 187 187 'name' => array( 188 188 'type' => 'string', 189 'description' => __( 'Full name of continent.', 'woocommerce ' ),189 'description' => __( 'Full name of continent.', 'woocommerce-services' ), 190 190 'context' => array( 'view' ), 191 191 'readonly' => true, … … 193 193 'countries' => array( 194 194 'type' => 'array', 195 'description' => __( 'List of countries on this continent.', 'woocommerce ' ),195 'description' => __( 'List of countries on this continent.', 'woocommerce-services' ), 196 196 'context' => array( 'view' ), 197 197 'readonly' => true, … … 203 203 'code' => array( 204 204 'type' => 'string', 205 'description' => __( 'ISO3166 alpha-2 country code.', 'woocommerce ' ),205 'description' => __( 'ISO3166 alpha-2 country code.', 'woocommerce-services' ), 206 206 'context' => array( 'view' ), 207 207 'readonly' => true, … … 209 209 'currency_code' => array( 210 210 'type' => 'string', 211 'description' => __( 'Default ISO4127 alpha-3 currency code for the country.', 'woocommerce ' ),211 'description' => __( 'Default ISO4127 alpha-3 currency code for the country.', 'woocommerce-services' ), 212 212 'context' => array( 'view' ), 213 213 'readonly' => true, … … 215 215 'currency_pos' => array( 216 216 'type' => 'string', 217 'description' => __( 'Currency symbol position for this country.', 'woocommerce ' ),217 'description' => __( 'Currency symbol position for this country.', 'woocommerce-services' ), 218 218 'context' => array( 'view' ), 219 219 'readonly' => true, … … 221 221 'decimal_sep' => array( 222 222 'type' => 'string', 223 'description' => __( 'Decimal separator for displayed prices for this country.', 'woocommerce ' ),223 'description' => __( 'Decimal separator for displayed prices for this country.', 'woocommerce-services' ), 224 224 'context' => array( 'view' ), 225 225 'readonly' => true, … … 227 227 'dimension_unit' => array( 228 228 'type' => 'string', 229 'description' => __( 'The unit lengths are defined in for this country.', 'woocommerce ' ),229 'description' => __( 'The unit lengths are defined in for this country.', 'woocommerce-services' ), 230 230 'context' => array( 'view' ), 231 231 'readonly' => true, … … 233 233 'name' => array( 234 234 'type' => 'string', 235 'description' => __( 'Full name of country.', 'woocommerce ' ),235 'description' => __( 'Full name of country.', 'woocommerce-services' ), 236 236 'context' => array( 'view' ), 237 237 'readonly' => true, … … 239 239 'num_decimals' => array( 240 240 'type' => 'integer', 241 'description' => __( 'Number of decimal points shown in displayed prices for this country.', 'woocommerce ' ),241 'description' => __( 'Number of decimal points shown in displayed prices for this country.', 'woocommerce-services' ), 242 242 'context' => array( 'view' ), 243 243 'readonly' => true, … … 245 245 'states' => array( 246 246 'type' => 'array', 247 'description' => __( 'List of states in this country.', 'woocommerce ' ),247 'description' => __( 'List of states in this country.', 'woocommerce-services' ), 248 248 'context' => array( 'view' ), 249 249 'readonly' => true, … … 255 255 'code' => array( 256 256 'type' => 'string', 257 'description' => __( 'State code.', 'woocommerce ' ),257 'description' => __( 'State code.', 'woocommerce-services' ), 258 258 'context' => array( 'view' ), 259 259 'readonly' => true, … … 261 261 'name' => array( 262 262 'type' => 'string', 263 'description' => __( 'Full name of state.', 'woocommerce ' ),263 'description' => __( 'Full name of state.', 'woocommerce-services' ), 264 264 'context' => array( 'view' ), 265 265 'readonly' => true, … … 270 270 'thousand_sep' => array( 271 271 'type' => 'string', 272 'description' => __( 'Thousands separator for displayed prices in this country.', 'woocommerce ' ),272 'description' => __( 'Thousands separator for displayed prices in this country.', 'woocommerce-services' ), 273 273 'context' => array( 'view' ), 274 274 'readonly' => true, … … 276 276 'weight_unit' => array( 277 277 'type' => 'string', 278 'description' => __( 'The unit weights are defined in for this country.', 'woocommerce ' ),278 'description' => __( 'The unit weights are defined in for this country.', 'woocommerce-services' ), 279 279 'context' => array( 'view' ), 280 280 'readonly' => true, -
woocommerce-services/tags/3.7.1/classes/wc-api-dev/class-wc-rest-dev-data-controller.php
r2498257 r3732427 67 67 public function get_items_permissions_check( $request ) { 68 68 if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { 69 return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce ' ), array( 'status' => rest_authorization_required_code() ) );69 return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce-services' ), array( 'status' => rest_authorization_required_code() ) ); 70 70 } 71 71 … … 81 81 public function get_item_permissions_check( $request ) { 82 82 if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { 83 return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view this resource.', 'woocommerce ' ), array( 'status' => rest_authorization_required_code() ) );83 return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view this resource.', 'woocommerce-services' ), array( 'status' => rest_authorization_required_code() ) ); 84 84 } 85 85 … … 99 99 array( 100 100 'slug' => 'continents', 101 'description' => __( 'List of supported continents, countries, and states.', 'woocommerce ' ),101 'description' => __( 'List of supported continents, countries, and states.', 'woocommerce-services' ), 102 102 ), 103 103 array( 104 104 'slug' => 'countries', 105 'description' => __( 'List of supported states in a given country.', 'woocommerce ' ),105 'description' => __( 'List of supported states in a given country.', 'woocommerce-services' ), 106 106 ), 107 107 array( 108 108 'slug' => 'currencies', 109 'description' => __( 'List of supported currencies.', 'woocommerce ' ),109 'description' => __( 'List of supported currencies.', 'woocommerce-services' ), 110 110 ), 111 111 ); … … 174 174 'properties' => array( 175 175 'slug' => array( 176 'description' => __( 'Data resource ID.', 'woocommerce ' ),176 'description' => __( 'Data resource ID.', 'woocommerce-services' ), 177 177 'type' => 'string', 178 178 'context' => array( 'view' ), … … 180 180 ), 181 181 'description' => array( 182 'description' => __( 'Data resource description.', 'woocommerce ' ),182 'description' => __( 'Data resource description.', 'woocommerce-services' ), 183 183 'type' => 'string', 184 184 'context' => array( 'view' ), -
woocommerce-services/tags/3.7.1/i18n/languages/woocommerce-services.pot
r3707890 r3732427 5 5 "Project-Id-Version: _s woocommerce-services\n" 6 6 "Report-Msgid-Bugs-To: \n" 7 "POT-Creation-Date: 2026- 09-22T17:54:57.634Z\n"7 "POT-Creation-Date: 2026-10-07T11:06:28.728Z\n" 8 8 "MIME-Version: 1.0\n" 9 9 "Content-Type: text/plain; charset=UTF-8\n" … … 110 110 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 111 111 #: dist/chunks/13f51058cf091ad1e827.min.js:1 112 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1112 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 113 113 msgid "Create shipping label" 114 114 msgid_plural "Create shipping labels" … … 117 117 118 118 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 119 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1119 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 120 120 msgid "Track Package" 121 121 msgid_plural "Track Packages" … … 124 124 125 125 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 126 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1126 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 127 127 msgid "Create new label" 128 128 msgstr "" 129 129 130 130 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 131 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1131 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 132 132 msgid "Connection error: unable to create label at this time" 133 133 msgstr "" 134 134 135 135 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 136 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1136 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 137 137 msgid "%(itemCount)d item was fulfilled on {{span}}%(createdDate)s{{/span}}" 138 138 msgid_plural "" … … 142 142 143 143 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 144 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1144 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 145 145 msgid "%(itemCount)d item is ready to be fulfilled" 146 146 msgid_plural "%(itemCount)d items are ready to be fulfilled" … … 804 804 #: dist/chunks/13f51058cf091ad1e827.min.js:1 805 805 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 806 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1806 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 807 807 msgid "Try WooCommerce Shipping" 808 808 msgstr "" … … 810 810 #: dist/chunks/13f51058cf091ad1e827.min.js:1 811 811 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 812 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1812 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 813 813 msgid "A new WooCommerce Shipping experience is now available" 814 814 msgstr "" … … 816 816 #: dist/chunks/13f51058cf091ad1e827.min.js:1 817 817 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 818 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1818 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 819 819 msgid "" 820 820 "We'll ensure a seamless transition by allowing you to migrate all your " … … 824 824 #: dist/chunks/13f51058cf091ad1e827.min.js:1 825 825 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 826 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1826 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 827 827 msgid "Here's what you can expect from the new shipping experience:" 828 828 msgstr "" … … 830 830 #: dist/chunks/13f51058cf091ad1e827.min.js:1 831 831 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 832 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1832 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 833 833 msgid "A seamless transition" 834 834 msgstr "" … … 836 836 #: dist/chunks/13f51058cf091ad1e827.min.js:1 837 837 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 838 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1838 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 839 839 msgid "" 840 840 "Automatically migrate all your compatible settings and shipment history to " … … 844 844 #: dist/chunks/13f51058cf091ad1e827.min.js:1 845 845 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 846 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1846 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 847 847 msgid "Print and save" 848 848 msgstr "" … … 850 850 #: dist/chunks/13f51058cf091ad1e827.min.js:1 851 851 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 852 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1852 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 853 853 msgid "" 854 854 "Speed up label creation with a streamlined process to print and save your " … … 858 858 #: dist/chunks/13f51058cf091ad1e827.min.js:1 859 859 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 860 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1860 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 861 861 msgid "USPS and DHL Express" 862 862 msgstr "" … … 864 864 #: dist/chunks/13f51058cf091ad1e827.min.js:1 865 865 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 866 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1866 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 867 867 msgid "" 868 868 "Ship your productss using trusted shipping carriers like USPS and DHL " … … 872 872 #: dist/chunks/13f51058cf091ad1e827.min.js:1 873 873 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 874 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1874 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 875 875 msgid "Enhanced label purchase flow" 876 876 msgstr "" … … 878 878 #: dist/chunks/13f51058cf091ad1e827.min.js:1 879 879 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 880 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1880 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 881 881 msgid "" 882 882 "Experience a smoother label purchasing process with our updated interface." … … 885 885 #: dist/chunks/13f51058cf091ad1e827.min.js:1 886 886 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 887 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1887 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 888 888 msgid "Maybe later" 889 889 msgstr "" -
woocommerce-services/tags/3.7.1/readme.txt
r3707890 r3732427 7 7 Tested up to: 7.1 8 8 WC requires at least: 10.9 9 WC tested up to: 11. 110 Stable tag: 3.7. 09 WC tested up to: 11.2 10 Stable tag: 3.7.1 11 11 License: GPLv2 or later 12 12 License URI: http://www.gnu.org/licenses/gpl-2.0.html … … 71 71 == Changelog == 72 72 73 = 3.7.1 - 2026-10-07 = 74 * Add - Check a US store address when it changes, and show a notice in the admin when its state or ZIP code looks wrong or is missing, since either can make automated taxes charge the wrong tax or none. 75 * Fix - Stop the classic checkout from jumping to the top of the page and taking focus away from the ZIP code field when an invalid ZIP code is entered, which could loop endlessly with address autocomplete enabled. 76 * Fix - Stop a store address with a missing or badly formatted ZIP code from blocking checkout with "ZIP Code is not formatted correctly." The order now goes through, but its tax is not looked up: it gets only the tax rates saved by earlier lookups for the same address, or no tax. A notice in the admin asks you to fix the ZIP code. 77 * Fix - Send the street with the address when you click Recalculate on an order that has no shipping address, so tax is not calculated from the ZIP code alone. Recalculate also no longer freezes when the city contains a double quote. 78 * Fix - Stop automated tax lookups from overwriting a tax rate you set up for a whole state with no ZIP code or city, which could leave in-person and mobile app orders with no tax. 79 * Fix - Recalculating an order in the admin now charges the tax rates returned by the tax service, as checkout does. Other tax rates you set up for the same address are no longer added on top. 80 * Fix - Update an order's tax when its items, quantities, fees, shipping or coupons change through the REST API or the WooCommerce app, using the tax rates recorded when the order was placed. Since 3.6.8 the tax stayed at its original amount after these edits, and each change is now recorded in an order note. 81 * Fix - Stop putting tax back on an order that was made VAT exempt after it was placed. 82 * Fix - Look up the tax for the new address when an order's address changes through the REST API or the WooCommerce app. If the tax service cannot answer, the order keeps its tax and a note says so. 83 * Fix - Calculate tax with WooCommerce Tax for orders created through the REST API, the WooCommerce app or point of sale, instead of using whatever tax rates happened to be stored for that area. 84 * Fix - Keep the TaxJar product tax code when recalculating an order in the admin for tax classes whose name has more than one word, such as "Reduced rate 12345", so the order is taxed the same as at checkout. Products in the built-in "Zero rate" class also stay tax-free when the order is recalculated. 85 * Fix - Unslash and sanitize request values in the label reports, settings, migration survey and tracking code, and stop using print_r in log messages. 86 * Fix - Require a nonce on the setup banner's Terms of Service and dismiss links, so they cannot be triggered by loading a crafted URL. 87 * Fix - Stop Recalculate and saving an order's items in the admin from logging a PHP notice for every item and saving an extra tax line with no name, which other plugins could see before it was removed again. 88 * Fix - Stop REST requests the site would refuse from writing to the WooCommerce Tax log, and stop REST errors from replacing the error notice shown to store managers. Request bodies are shortened and only logged when debug logging is on. 89 * Fix - Prevent a fatal error in the tax calculation when custom code creates the TaxJar integration without a store notices notifier. 90 * Fix - Prevent a rare fatal error when WooCommerce Tax registers its Store API data on incomplete WooCommerce installations. 91 * Fix - Stop store admins other than the Jetpack connection owner from accepting the WooCommerce Tax Terms of Service through the REST API or the banner shown after connecting. 92 * Fix - Check that the order exists, and that a shipping label belongs to it, before buying a label, getting rates, checking a label's status or refunding a label. Customs details are only saved to products on that order. 93 * Tweak - Block direct access to the remaining class files and declare the license in the plugin header. 94 * Tweak - Include composer.json in the plugin package. 95 * Tweak - Bind the service IDs in the enabled services query with prepare() instead of escaping them by hand. 96 * Tweak - WooCommerce 11.2 Compatibility. 97 73 98 = 3.7.0 - 2026-09-22 = 74 99 * Fix - Prevent a fatal error that stopped WooCommerce Tax from being deactivated while WooCommerce was inactive. 75 100 * Tweak - Update the bundled JavaScript libraries. 76 101 * Tweak - Build the plugin's JavaScript on Node.js 24 and drop the wp-calypso submodule, keeping vendored copies of the modules it provided. 102 * Tweak - Load every translatable string from the plugin's own text domain and add translator notes to strings with placeholders. 77 103 78 104 = 3.6.16 - 2026-09-16 = -
woocommerce-services/tags/3.7.1/src/Integrations/WooCommerceBlocksIntegration.php
r3635005 r3732427 127 127 $script_url, 128 128 $script_dependencies, 129 null, 129 null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Built asset whose URL carries the build hash, so the version is already in the file name. 130 130 true 131 131 ); -
woocommerce-services/tags/3.7.1/src/StoreApi/StoreApiExtensionController.php
r3331570 r3732427 11 11 12 12 use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; 13 use Exception;13 use Throwable; 14 14 15 15 defined( 'ABSPATH' ) || exit; … … 93 93 ) 94 94 ); 95 } catch ( Exception $e ) { 96 wc_get_logger()->debug( 'Failed to register endpoint data for extension', array( 'error', $e->getMessage() ) ); 95 } catch ( Throwable $e ) { 96 wc_get_logger()->debug( 97 'Failed to register endpoint data for extension', 98 array( 99 'source' => 'woocommerce-services', 100 'error' => $e->getMessage(), 101 ) 102 ); 97 103 } 98 104 } … … 111 117 ) 112 118 ); 113 } catch ( Exception $e ) { 114 wc_get_logger()->debug( 'Failed to register update callback for extension', array( 'error', $e->getMessage() ) ); 119 } catch ( Throwable $e ) { 120 wc_get_logger()->debug( 121 'Failed to register update callback for extension', 122 array( 123 'source' => 'woocommerce-services', 124 'error' => $e->getMessage(), 125 ) 126 ); 115 127 } 116 128 } -
woocommerce-services/tags/3.7.1/src/StoreNotices/StoreNoticesController.php
r3392766 r3732427 21 21 22 22 /** 23 * Selector of the classic checkout notices container, replaced by the update_order_review fragment. 24 * 25 * @var string 26 */ 27 const CLASSIC_CHECKOUT_NOTICES_SELECTOR = 'div.wcservices-checkout-notices'; 28 29 /** 23 30 * Notifier instance. 24 31 * … … 37 44 add_action( 'woocommerce_after_calculate_totals', array( $this, 'maybe_display_notices' ), 30 ); 38 45 add_filter( 'woocommerce_store_api_cart_errors', array( $this, 'add_store_api_cart_errors' ), 10, 2 ); 46 add_action( 'woocommerce_checkout_before_customer_details', array( $this, 'print_classic_checkout_notices_container' ) ); 47 add_filter( 'woocommerce_update_order_review_fragments', array( $this, 'add_update_order_review_fragment' ) ); 39 48 } 40 49 … … 47 56 } 48 57 58 // WooCommerce marks update_order_review as failed when the notice queue is not empty, and the classic 59 // checkout then blurs every field, which triggers another update: an endless loop. The notices are sent 60 // as a fragment instead (see add_update_order_review_fragment()). Placing the order still queues them. 61 if ( did_action( 'woocommerce_checkout_update_order_review' ) ) { 62 return; 63 } 64 49 65 $this->notifier->print_notices(); 50 66 $this->notifier::clear_notices(); 67 } 68 69 /** 70 * Print the classic checkout container that the update_order_review fragment fills with the notices. 71 */ 72 public function print_classic_checkout_notices_container() { 73 echo '<div class="wcservices-checkout-notices"></div>'; 74 } 75 76 /** 77 * Send the notices to the classic checkout as an update_order_review fragment. 78 * 79 * The container is replaced even when there are no notices, so a corrected address clears the message. 80 * 81 * @param array $fragments Checkout fragments. 82 * 83 * @return array 84 */ 85 public function add_update_order_review_fragment( $fragments ) { 86 if ( ! is_array( $fragments ) ) { 87 return $fragments; 88 } 89 90 $fragments[ self::CLASSIC_CHECKOUT_NOTICES_SELECTOR ] = '<div class="wcservices-checkout-notices">' . $this->get_notices_html() . '</div>'; 91 $this->notifier::clear_notices(); 92 93 return $fragments; 94 } 95 96 /** 97 * Render the notices with the WooCommerce notice templates. 98 * 99 * @return string 100 */ 101 private function get_notices_html(): string { 102 $html = ''; 103 104 foreach ( StoreNoticesNotifier::get_notices() as $type => $notices ) { 105 // The types wc_print_notices() renders by default, so the fragment shows what the notice queue would. 106 if ( ! in_array( $type, array( 'error', 'success', 'notice' ), true ) ) { 107 continue; 108 } 109 110 foreach ( $notices as $notice ) { 111 $html .= wc_print_notice( $this->notifier->maybe_get_formatted_message( $notice['message'], $notice['data'] ), $type, array(), true ); 112 } 113 } 114 115 return $html; 51 116 } 52 117 -
woocommerce-services/tags/3.7.1/src/StoreNotices/StoreNoticesNotifier.php
r3331570 r3732427 132 132 */ 133 133 public function has_notice( string $message, string $type = 'notice', array $data = array(), string $group = '' ): bool { 134 if ( ! self::wc_session_exists() ) { 135 return false; 136 } 137 134 138 $notices = WC()->session->get( self::WC_SESSION_KEY, array() ); 135 139 -
woocommerce-services/tags/3.7.1/src/Tax/Address.php
r3640567 r3732427 38 38 39 39 use WC_Customer; 40 use WC_Order; 40 41 use WP_Error; 41 42 … … 267 268 268 269 /** 270 * Build from an order's saved billing or shipping address. 271 * 272 * @param WC_Order $order Order to read. 273 * @param string $type 'billing' or 'shipping'. Anything else reads billing. 274 * @return self 275 */ 276 public static function from_order( WC_Order $order, string $type ): self { 277 // Clean the same way from_post_request() does, so a saved address compares 278 // equal to the same address arriving through the request. 279 if ( 'shipping' === $type ) { 280 return new self( 281 (string) wc_clean( $order->get_shipping_country() ), 282 (string) wc_clean( $order->get_shipping_state() ), 283 (string) wc_clean( $order->get_shipping_postcode() ), 284 (string) wc_clean( $order->get_shipping_city() ), 285 (string) wc_clean( $order->get_shipping_address_1() ) 286 ); 287 } 288 289 return new self( 290 (string) wc_clean( $order->get_billing_country() ), 291 (string) wc_clean( $order->get_billing_state() ), 292 (string) wc_clean( $order->get_billing_postcode() ), 293 (string) wc_clean( $order->get_billing_city() ), 294 (string) wc_clean( $order->get_billing_address_1() ) 295 ); 296 } 297 298 /** 269 299 * Build from an admin-order `$_POST` payload. 270 300 * -
woocommerce-services/tags/3.7.1/src/Utils.php
r3350055 r3732427 68 68 */ 69 69 public static function get_sanitized_request_data( string $key, string $default = '' ): string { 70 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This accessor is how the nonce itself is read: every caller is a wp_verify_nonce() call in WC_Connect_Migration_Survey. Verifying a nonce here would be circular; the callers are responsible for it. 70 71 return sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ?? $default ) ); 71 72 } -
woocommerce-services/tags/3.7.1/vendor/composer/autoload_classmap.php
r3698279 r3732427 94 94 'Automattic\\WCServices\\StoreNotices\\StoreNoticesNotifier' => $baseDir . '/src/StoreNotices/StoreNoticesNotifier.php', 95 95 'Automattic\\WCServices\\Tax\\Address' => $baseDir . '/src/Tax/Address.php', 96 'Automattic\\WCServices\\Tax\\StoreAddressNotice' => $baseDir . '/src/Tax/StoreAddressNotice.php', 97 'Automattic\\WCServices\\Tax\\StoreAddressVerifier' => $baseDir . '/src/Tax/StoreAddressVerifier.php', 96 98 'Automattic\\WCServices\\Utils' => $baseDir . '/src/Utils.php', 97 99 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', -
woocommerce-services/tags/3.7.1/vendor/composer/autoload_static.php
r3707890 r3732427 119 119 'Automattic\\WCServices\\StoreNotices\\StoreNoticesNotifier' => __DIR__ . '/../..' . '/src/StoreNotices/StoreNoticesNotifier.php', 120 120 'Automattic\\WCServices\\Tax\\Address' => __DIR__ . '/../..' . '/src/Tax/Address.php', 121 'Automattic\\WCServices\\Tax\\StoreAddressNotice' => __DIR__ . '/../..' . '/src/Tax/StoreAddressNotice.php', 122 'Automattic\\WCServices\\Tax\\StoreAddressVerifier' => __DIR__ . '/../..' . '/src/Tax/StoreAddressVerifier.php', 121 123 'Automattic\\WCServices\\Utils' => __DIR__ . '/../..' . '/src/Utils.php', 122 124 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', -
woocommerce-services/tags/3.7.1/vendor/composer/installed.php
r3707890 r3732427 4 4 'pretty_version' => 'dev-trunk', 5 5 'version' => 'dev-trunk', 6 'reference' => ' 52b37612e215ad51a541900cf94eca91c8691fee',6 'reference' => 'd726c3d7abe4c8fa65a77d8cde0a81087380213a', 7 7 'type' => 'library', 8 8 'install_path' => __DIR__ . '/../../', … … 14 14 'pretty_version' => 'dev-trunk', 15 15 'version' => 'dev-trunk', 16 'reference' => ' 52b37612e215ad51a541900cf94eca91c8691fee',16 'reference' => 'd726c3d7abe4c8fa65a77d8cde0a81087380213a', 17 17 'type' => 'library', 18 18 'install_path' => __DIR__ . '/../../', -
woocommerce-services/tags/3.7.1/vendor/composer/jetpack_autoload_classmap.php
r3698279 r3732427 367 367 'path' => $baseDir . '/src/Tax/Address.php' 368 368 ), 369 'Automattic\\WCServices\\Tax\\StoreAddressNotice' => array( 370 'version' => 'dev-trunk', 371 'path' => $baseDir . '/src/Tax/StoreAddressNotice.php' 372 ), 373 'Automattic\\WCServices\\Tax\\StoreAddressVerifier' => array( 374 'version' => 'dev-trunk', 375 'path' => $baseDir . '/src/Tax/StoreAddressVerifier.php' 376 ), 369 377 'Automattic\\WCServices\\Utils' => array( 370 378 'version' => 'dev-trunk', -
woocommerce-services/tags/3.7.1/woocommerce-services.php
r3707890 r3732427 9 9 * Text Domain: woocommerce-services 10 10 * Domain Path: /i18n/languages/ 11 * Version: 3.7.0 11 * License: GPLv2 or later 12 * License URI: https://www.gnu.org/licenses/gpl-2.0.html 13 * Version: 3.7.1 12 14 * Requires Plugins: woocommerce 13 15 * Requires PHP: 7.4 … … 15 17 * Tested up to: 7.1 16 18 * WC requires at least: 10.9 17 * WC tested up to: 11. 119 * WC tested up to: 11.2 18 20 * 19 21 * Copyright (c) 2017-2023 Automattic … … 94 96 */ 95 97 const DISMISS_SERVER_NOTICE_NONCE_NAME = '_wc_connect_notice_nonce'; 98 99 /** 100 * Most bytes of a request body log_rest_api_errors() writes to the log. 101 * 102 * @var int 103 */ 104 private const REST_ERROR_LOG_BODY_LIMIT = 1024; 96 105 97 106 /** … … 1385 1394 * Note: intended to be hooked into 'rest_request_before_callbacks' 1386 1395 * 1387 * @param WP_HTTP_Response $response Result to send to the client. Usually a WP_REST_Response. 1388 * @param WP_REST_Server $handler ResponseHandler instance (usually WP_REST_Server). 1389 * @param WP_REST_Request $request Request used to generate the response. 1396 * WordPress applies that filter before the route's permission check, and skips the 1397 * check when the request already failed (invalid JSON, missing or invalid params). 1398 * So only requests the route itself allows are logged. The error line is always 1399 * written; the request body only when debug logging is on, shortened and escaped. 1400 * The admin error notice is not touched. 1401 * 1402 * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client. Usually a WP_REST_Response. 1403 * @param array $handler Route handler used for the request. 1404 * @param WP_REST_Request $request Request used to generate the response. 1390 1405 * 1391 1406 * @return mixed - pass through value of $response. 1392 1407 */ 1393 1408 public function log_rest_api_errors( $response, $handler, $request ) { 1394 if ( ! is_wp_error( $response ) ) {1409 if ( ! is_wp_error( $response ) || ! $request instanceof WP_REST_Request ) { 1395 1410 return $response; 1396 1411 } 1397 1412 1398 if ( 0 === strpos( $request->get_route(), '/wc/v1/connect/' ) ) { 1399 $route_info = $request->get_method() . ' ' . $request->get_route(); 1400 1401 $this->get_logger()->error( $response, $route_info ); 1402 $this->get_logger()->error( $route_info, $request->get_body() ); 1413 if ( 0 !== strpos( $request->get_route(), '/wc/v1/connect/' ) || ! $this->rest_route_allows_request( $handler, $request ) ) { 1414 return $response; 1415 } 1416 1417 $logger = $this->get_logger(); 1418 $route_info = $request->get_method() . ' ' . $this->encode_for_log( $request->get_route() ); 1419 1420 // log() rather than error(): a REST error must not replace the admin error notice. 1421 $logger->log( $response, $route_info, true ); 1422 1423 // A request with no body has nothing to add. 1424 if ( $logger->is_logging_enabled() && '' !== (string) $request->get_body() ) { 1425 $logger->log( $route_info, $this->format_rest_body_for_log( $request->get_body() ) ); 1403 1426 } 1404 1427 1405 1428 return $response; 1429 } 1430 1431 /** 1432 * Whether the route's own permission check allows a request. 1433 * 1434 * WordPress skips its own check for a request that failed validation. It still 1435 * calls the callback afterwards to build the Allow header, which does not decide 1436 * access. A missing or non-callable check counts as denied. 1437 * 1438 * @param array|mixed $handler Route handler used for the request. 1439 * @param WP_REST_Request $request Request used to generate the response. 1440 * 1441 * @return bool 1442 */ 1443 private function rest_route_allows_request( $handler, $request ) { 1444 if ( ! is_array( $handler ) || empty( $handler['permission_callback'] ) || ! is_callable( $handler['permission_callback'] ) ) { 1445 return false; 1446 } 1447 1448 $permission = call_user_func( $handler['permission_callback'], $request ); 1449 1450 return ! is_wp_error( $permission ) && false !== $permission && null !== $permission; 1451 } 1452 1453 /** 1454 * JSON-encode request text for the log. 1455 * 1456 * ASCII line breaks and control characters are escaped, so the text cannot add lines 1457 * of its own to the log. Invalid UTF-8 is replaced rather than dropping the text. 1458 * 1459 * @param string $text Text taken from the request. 1460 * 1461 * @return string The quoted, escaped text. 1462 */ 1463 private function encode_for_log( $text ) { 1464 $encoded = wp_json_encode( (string) $text, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ); 1465 1466 return false === $encoded ? '""' : $encoded; 1467 } 1468 1469 /** 1470 * Describe a request body for the log: its length and an encoded excerpt. 1471 * 1472 * @param string $body Raw request body. 1473 * 1474 * @return string 1475 */ 1476 private function format_rest_body_for_log( $body ) { 1477 $body = (string) $body; 1478 $length = strlen( $body ); 1479 $is_long = $length > self::REST_ERROR_LOG_BODY_LIMIT; 1480 $excerpt = $body; 1481 1482 if ( $is_long ) { 1483 $excerpt = function_exists( 'mb_strcut' ) 1484 ? mb_strcut( $body, 0, self::REST_ERROR_LOG_BODY_LIMIT, 'UTF-8' ) 1485 : substr( $body, 0, self::REST_ERROR_LOG_BODY_LIMIT ); 1486 } 1487 1488 return sprintf( 'body, %d bytes: %s%s', $length, $this->encode_for_log( $excerpt ), $is_long ? '...' : '' ); 1406 1489 } 1407 1490 … … 1783 1866 1784 1867 // Order list page doesn't have the action parameter in the querystring. 1868 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: distinguishes WooCommerce's order list screen from the single order screen, which is the only one that carries the action arg. It only gates which assets are enqueued. 1785 1869 if ( isset( $_GET['action'] ) ) { 1786 1870 return false; … … 1839 1923 } 1840 1924 1841 wp_register_script( 'wc_services_admin_pointers', $this->wc_connect_base_url . 'woocommerce-services-admin-pointers-' . $plugin_version . '.js', array( 'wp-pointer', 'jquery' ), null ); 1842 wp_register_style( 'wc_connect_banner', $this->wc_connect_base_url . 'woocommerce-services-banner-' . $plugin_version . '.css', array(), null ); 1843 wp_register_script( 'wc_connect_banner', $this->wc_connect_base_url . 'woocommerce-services-banner-' . $plugin_version . '.js', array(), null ); 1925 wp_register_script( 'wc_services_admin_pointers', $this->wc_connect_base_url . 'woocommerce-services-admin-pointers-' . $plugin_version . '.js', array( 'wp-pointer', 'jquery' ), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, WordPress.WP.EnqueuedResourceParameters.NotInFooter -- Served from the Connect Server and the version is already in the file name, so no ?ver= is wanted. Registered in the head because the wp-pointer it drives runs before the footer. 1926 wp_register_style( 'wc_connect_banner', $this->wc_connect_base_url . 'woocommerce-services-banner-' . $plugin_version . '.css', array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Served from the Connect Server and the version is already in the file name, so no ?ver= is wanted. 1927 wp_register_script( 'wc_connect_banner', $this->wc_connect_base_url . 'woocommerce-services-banner-' . $plugin_version . '.js', array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, WordPress.WP.EnqueuedResourceParameters.NotInFooter -- Served from the Connect Server and the version is already in the file name, so no ?ver= is wanted. Registered in the head so the banner is styled and wired before first paint. 1844 1928 1845 1929 $i18n_json = $this->get_i18n_json(); … … 1869 1953 1870 1954 foreach ( $shipping_service_ids as $shipping_service_id ) { 1955 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Asks only whether an enabled zone method row exists for this method id; WC_Shipping_Zones has no such reader and would instantiate every shipping method to answer it. Not cached because zone methods are enabled and disabled from WooCommerce's own screens with no hook this plugin could invalidate on, and a stale answer would misreport the store's active services to the Connect server. 1871 1956 $is_active = $wpdb->get_var( 1872 1957 $wpdb->prepare( … … 2071 2156 $meta_table_to_check = OrderUtil::get_table_for_order_meta(); 2072 2157 2158 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Indexed EXISTS probe on the order meta table; core offers no "does any order carry this meta key" API, and wc_get_orders() cannot run this early (the check is reachable from the all_plugins filter). Left uncached deliberately: the answer decides whether the plugin's shipping features load at all, and the meta is also written by the WooCommerce Shipping migration, so there is no write this plugin could hang invalidation on, and serving a stale "no labels" would hide shipping from a store that has it. 2073 2159 return (bool) $wpdb->get_var( 2074 2160 $wpdb->prepare( … … 2168 2254 'https://cdn.sift.com/s.js', 2169 2255 array(), 2170 null, 2256 null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Third-party script served from Sift's CDN, which versions it server side; a ?ver= we invent would be wrong. 2171 2257 array( 2172 2258 'strategy' => 'defer', … … 2252 2338 $encoded_arguments = wp_json_encode( $extra_args ); 2253 2339 ?> 2254 <div class="wcc-root woocommerce <?php echo esc_attr( $root_view ); ?>" data-args="<?php echo wc_esc_json( $encoded_arguments ); ?>">2340 <div class="wcc-root woocommerce <?php echo esc_attr( $root_view ); ?>" data-args="<?php echo wc_esc_json( $encoded_arguments ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wc_esc_json() escapes JSON for use in an HTML attribute. ?>"> 2255 2341 <span class="form-troubles" style="opacity: 0"> 2256 <?php printf( esc_html__( 'Section not loading? Visit the <a href="%s">status page</a> for troubleshooting steps.', 'woocommerce-services' ), esc_url( $debug_page_uri ) ); ?> 2342 <?php 2343 /* translators: %s: URL of the status page */ 2344 printf( esc_html__( 'Section not loading? Visit the <a href="%s">status page</a> for troubleshooting steps.', 'woocommerce-services' ), esc_url( $debug_page_uri ) ); 2345 ?> 2257 2346 </span> 2258 2347 </div> … … 2389 2478 sprintf( 2390 2479 /* translators: %s: documentation URL */ 2391 __( $banner->message, 'woocommerce-services' ), 2480 __( $banner->message, 'woocommerce-services' ), // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- The message is supplied by the WooCommerce Tax server. 2392 2481 'https://woocommerce.com/document/woocommerce-shipping-and-tax/woocommerce-shipping/#how-do-i-migrate-from-wcst' 2393 2482 ) . … … 2407 2496 $plugin_version = self::get_wcs_version(); 2408 2497 wp_register_style( 'wcst_wcshipping_migration_admin_notice', $this->wc_connect_base_url . 'woocommerce-services-wcshipping-migration-admin-notice-' . $plugin_version . '.css', array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion 2409 wp_register_script( 'wcst_wcshipping_migration_admin_notice', $this->wc_connect_base_url . 'woocommerce-services-wcshipping-migration-admin-notice-' . $plugin_version . '.js', array(), null ); 2498 wp_register_script( 'wcst_wcshipping_migration_admin_notice', $this->wc_connect_base_url . 'woocommerce-services-wcshipping-migration-admin-notice-' . $plugin_version . '.js', array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, WordPress.WP.EnqueuedResourceParameters.NotInFooter -- Served from the Connect Server and the version is already in the file name, so no ?ver= is wanted. Registered in the head alongside the stylesheet above, which carries the same note. 2410 2499 wp_localize_script( 2411 2500 'wcst_wcshipping_migration_admin_notice', -
woocommerce-services/trunk/classes/class-wc-connect-api-client-live.php
r3598126 r3732427 71 71 'wcc_server_error', 72 72 sprintf( 73 /* translators: %d: HTTP response code */ 73 74 __( 'Error: The WooCommerce Tax server returned HTTP code: %d', 'woocommerce-services' ), 74 75 $response_code … … 92 93 'wcc_server_empty_response', 93 94 sprintf( 95 /* translators: %d: HTTP response code */ 94 96 __( 'Error: The WooCommerce Tax server returned ( %d ) and an empty response body.', 'woocommerce-services' ), 95 97 $response_code -
woocommerce-services/trunk/classes/class-wc-connect-api-client.php
r3452871 r3732427 97 97 'product_missing_weight', 98 98 sprintf( 99 /* translators: %d: product ID */ 99 100 __( 'Product ( ID: %d ) did not include a weight. Shipping rates cannot be calculated.', 'woocommerce-services' ), 100 101 $product->get_id() … … 112 113 'product_missing_dimension', 113 114 sprintf( 115 /* translators: %d: product ID */ 114 116 __( 'Product ( ID: %d ) is missing a dimension value. Shipping rates cannot be calculated.', 'woocommerce-services' ), 115 117 $product->get_id() … … 616 618 617 619 $data = array( 618 'host' => parse_url( $request_url, PHP_URL_HOST ), // host URL.619 'request_uri' => parse_url( $request_url, PHP_URL_PATH ), // endpoint URL.620 'host' => wp_parse_url( $request_url, PHP_URL_HOST ), // host URL. 621 'request_uri' => wp_parse_url( $request_url, PHP_URL_PATH ), // endpoint URL. 620 622 'method' => $method, 621 623 ); … … 670 672 sprintf( 671 673 '%04X%04X-%04X-%04X-%04X-%04X%04X%04X', 672 mt_rand( 0, 65535 ),673 mt_rand( 0, 65535 ),674 mt_rand( 0, 65535 ),675 mt_rand( 16384, 20479 ),676 mt_rand( 32768, 49151 ),677 mt_rand( 0, 65535 ),678 mt_rand( 0, 65535 ),679 mt_rand( 0, 65535 )674 wp_rand( 0, 65535 ), 675 wp_rand( 0, 65535 ), 676 wp_rand( 0, 65535 ), 677 wp_rand( 16384, 20479 ), 678 wp_rand( 32768, 49151 ), 679 wp_rand( 0, 65535 ), 680 wp_rand( 0, 65535 ), 681 wp_rand( 0, 65535 ) 680 682 ) 681 683 ); -
woocommerce-services/trunk/classes/class-wc-connect-custom-surcharge.php
r3417327 r3732427 112 112 array( 113 113 'value' => 0.28, 114 'text' => __( 'Retail Delivery Fee', 'woocommerce _services' ),114 'text' => __( 'Retail Delivery Fee', 'woocommerce-services' ), 115 115 ), 116 116 $cart -
woocommerce-services/trunk/classes/class-wc-connect-debug-tools.php
r3289651 r3732427 90 90 global $wpdb; 91 91 92 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- WooCommerce's tax rate table has no core reader that can filter on tax_rate_name; caching is pointless because the matched rows are deleted immediately below and the tool only runs on an explicit click in WooCommerce » Status » Tools. 92 93 $found_ca_rates = $wpdb->get_results( 93 94 $wpdb->prepare( … … 124 125 125 126 echo '<div class="updated inline"><p>'; 127 /* translators: %1$d: number of deleted rows */ 126 128 printf( esc_html__( 'Successfully deleted %1$d rows from the database.', 'woocommerce-services' ), intval( $deleted_count ) ); 127 129 echo '</p></div>'; … … 137 139 138 140 $deleted_count = absint( 141 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Core has no API for deleting transients by name pattern, and the tool is only registered when wp_using_ext_object_cache() is false (see woocommerce_debug_tools()), so the options table is the authoritative store here. It runs on an explicit click in WooCommerce » Status » Tools. 139 142 $wpdb->query( 140 143 "DELETE FROM {$wpdb->options} WHERE option_name LIKE '%tj\_tax\_%';" … … 143 146 144 147 echo '<div class="updated inline"><p>'; 148 /* translators: %1$d: number of deleted transients */ 145 149 printf( esc_html__( 'Successfully deleted %1$d transients from the database.', 'woocommerce-services' ), intval( $deleted_count ) ); 146 150 echo '</p></div>'; -
woocommerce-services/trunk/classes/class-wc-connect-error-notice.php
r3698279 r3732427 78 78 79 79 private function notice_enabled() { 80 return WC_Connect_Options::get_option( 'error_notice', false );80 return WC_Connect_Options::get_option( 'error_notice', false ); 81 81 } 82 82 … … 119 119 $product_id = $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id(); 120 120 $message = sprintf( 121 /* translators: %1$s: product edit URL, %2$s: product name */ 121 122 __( '<strong>"%2$s" is missing weight, length, width, or height.</strong><br />Shipping rates cannot be calculated. <a href="%1$s">Enter dimensions and weight for %2$s</a> so your customers can purchase this item.', 'woocommerce-services' ), 122 123 get_edit_post_link( $product_id ), -
woocommerce-services/trunk/classes/class-wc-connect-extension-compatibility.php
r3310537 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Extension_Compatibility' ) ) { -
woocommerce-services/trunk/classes/class-wc-connect-functions.php
r3685245 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Functions' ) ) { … … 19 23 ( defined( 'REST_REQUEST' ) || defined( 'REST_API_REQUEST' ) || defined( 'XMLRPC_REQUEST' ) ) || 20 24 // Skip during Jetpack REST API proxy requests. 25 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing check: it only detects that the current request is Jetpack's REST proxy so the cart API call is skipped. Nothing is written and there is no form submission to carry a nonce. 21 26 ( isset( $_GET['rest_route'] ) && isset( $_GET['_for'] ) && ( 'jetpack' === $_GET['_for'] ) ) 22 27 ); … … 153 158 154 159 // Export Tax Rates 160 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Full dump of WooCommerce's tax rate table for the CSV backup taken right before the CA rate deletion tool wipes rows; core exposes no reader for the whole table, and a cached copy would defeat the point of backing up the current contents. 155 161 $rates = $wpdb->get_results( 156 162 $wpdb->prepare( … … 170 176 $rows = array( 171 177 array( 172 __( 'Country Code', 'woocommerce ' ),173 __( 'State Code', 'woocommerce ' ),174 __( 'ZIP/Postcode', 'woocommerce ' ),175 __( 'City', 'woocommerce ' ),176 __( 'Rate %', 'woocommerce ' ),177 __( 'Tax Name', 'woocommerce ' ),178 __( 'Priority', 'woocommerce ' ),179 __( 'Compound', 'woocommerce ' ),180 __( 'Shipping', 'woocommerce ' ),181 __( 'Tax Class', 'woocommerce ' ),178 __( 'Country Code', 'woocommerce-services' ), 179 __( 'State Code', 'woocommerce-services' ), 180 __( 'ZIP/Postcode', 'woocommerce-services' ), 181 __( 'City', 'woocommerce-services' ), 182 __( 'Rate %', 'woocommerce-services' ), 183 __( 'Tax Name', 'woocommerce-services' ), 184 __( 'Priority', 'woocommerce-services' ), 185 __( 'Compound', 'woocommerce-services' ), 186 __( 'Shipping', 'woocommerce-services' ), 187 __( 'Tax Class', 'woocommerce-services' ), 182 188 ), 183 189 ); 184 190 185 191 foreach ( $rates as $rate ) { 192 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads WooCommerce's tax rate locations table, which has no core reader for a single rate's postcodes/cities. Part of the one-shot CSV backup above, so each rate is read once and a cache would never be reused. 186 193 $postcodes = $wpdb->get_col( $wpdb->prepare( "SELECT location_code FROM {$wpdb->prefix}woocommerce_tax_rate_locations WHERE location_type='postcode' AND tax_rate_id = %d ORDER BY location_code", $rate->tax_rate_id ) ); 187 $cities = $wpdb->get_col( $wpdb->prepare( "SELECT location_code FROM {$wpdb->prefix}woocommerce_tax_rate_locations WHERE location_type='city' AND tax_rate_id = %d ORDER BY location_code", $rate->tax_rate_id ) ); 194 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Same one-shot CSV backup read as the postcode query above, for the rate's city locations. 195 $cities = $wpdb->get_col( $wpdb->prepare( "SELECT location_code FROM {$wpdb->prefix}woocommerce_tax_rate_locations WHERE location_type='city' AND tax_rate_id = %d ORDER BY location_code", $rate->tax_rate_id ) ); 188 196 189 197 $rows[] = array( … … 338 346 foreach ( $files as $file ) { 339 347 if ( ! file_exists( trailingslashit( $file['base'] ) . $file['file'] ) ) { 340 $file_handle = @fopen( trailingslashit( $file['base'] ) . $file['file'], 'wb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_ read_fopen348 $file_handle = @fopen( trailingslashit( $file['base'] ) . $file['file'], 'wb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen 341 349 if ( $file_handle ) { 342 fwrite( $file_handle, $file['content'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_ read_fwrite343 fclose( $file_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_ read_fclose350 fwrite( $file_handle, $file['content'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite 351 fclose( $file_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose 344 352 } 345 353 } -
woocommerce-services/trunk/classes/class-wc-connect-help-view.php
r3505023 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Help_View' ) ) { … … 59 63 'state' => 'error', 60 64 'message' => sprintf( 65 /* translators: %1$s: minimum required WooCommerce version, %2$s: installed WooCommerce version */ 61 66 __( 'WooCommerce %1$s or higher is required (You are running %2$s)', 'woocommerce-services' ), 62 67 WOOCOMMERCE_CONNECT_MINIMUM_WOOCOMMERCE_VERSION, … … 73 78 'state' => 'success', 74 79 'message' => sprintf( 80 /* translators: %s: installed WooCommerce version */ 75 81 __( 'WooCommerce %s is configured correctly', 'woocommerce-services' ), 76 82 WC()->version … … 166 172 167 173 $subtitle = sprintf( 174 /* translators: %s: shipping zone name */ 168 175 __( '%s Shipping Zone', 'woocommerce-services' ), 169 176 $enabled_service->zone_name … … 329 336 'state' => 'error', 330 337 'settings_link_type' => '', 338 /* translators: %s: store country code */ 331 339 'message' => sprintf( __( 'Your store\'s country (%s) is not supported. Automated taxes functionality is disabled', 'woocommerce-services' ), $store_country ), 332 340 ); -
woocommerce-services/trunk/classes/class-wc-connect-jetpack.php
r3685245 r3732427 5 5 use Automattic\Jetpack\Status; 6 6 use Automattic\Jetpack\Status\Host; 7 8 if ( ! defined( 'ABSPATH' ) ) { 9 exit; 10 } 7 11 8 12 if ( ! class_exists( 'WC_Connect_Jetpack' ) ) { -
woocommerce-services/trunk/classes/class-wc-connect-label-reports.php
r3204770 r3732427 2 2 3 3 use Automattic\WooCommerce\Utilities\OrderUtil; 4 5 if ( ! defined( 'ABSPATH' ) ) { 6 exit; 7 } 4 8 5 9 if ( ! class_exists( 'WC_Connect_Label_Reports' ) ) { … … 19 23 20 24 public function get_export_button() { 21 $current_range = ! empty( $_GET['range'] ) ? sanitize_text_field( $_GET['range'] ) : '7day'; 25 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: the range only lands in the suggested CSV filename of the export link. WooCommerce core supplies this query arg on its own report screens, so there is no submission of ours to nonce. 26 $current_range = ! empty( $_GET['range'] ) ? sanitize_text_field( wp_unslash( $_GET['range'] ) ) : '7day'; 22 27 ?> 23 28 <a … … 41 46 $table_name = OrderUtil::get_table_for_order_meta(); 42 47 $id_column = OrderUtil::custom_orders_table_usage_is_enabled() ? 'order_id' : 'post_id'; 48 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk read of one meta key across every order; core offers no API for that, and wc_get_orders() would hydrate every order object. The result is cached by the only caller, query_labels(), in the wcs_label_reports transient for 30 minutes. 43 49 $db_results = $wpdb->get_results( 44 50 $wpdb->prepare( … … 129 135 ); 130 136 131 $current_range = ! empty( $_GET['range'] ) ? sanitize_text_field( $_GET['range'] ) : '7day'; 137 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only date-range selection for the report; the value is allow-listed below and WC_Admin_Report::check_current_range_nonce() on the next lines verifies core's nonce for the one range ('custom') that carries user input. 138 $current_range = ! empty( $_GET['range'] ) ? sanitize_text_field( wp_unslash( $_GET['range'] ) ) : '7day'; 132 139 133 140 if ( ! in_array( $current_range, array( 'custom', 'year', 'last_month', 'month', '7day' ) ) ) { … … 198 205 <tr> 199 206 <th scope="row"> 200 <?php echo esc_html( get_date_from_gmt( date( 'Y-m-d H:i:s', intval( $label['created'] / 1000 ) ) ) ); ?>207 <?php echo esc_html( get_date_from_gmt( gmdate( 'Y-m-d H:i:s', intval( $label['created'] / 1000 ) ) ) ); ?> 201 208 </th> 202 209 <td> -
woocommerce-services/trunk/classes/class-wc-connect-logger.php
r3452871 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Logger' ) ) { … … 105 109 * 106 110 * @param WP_Error|string $message Message to log 107 * @param string $context Optional context (e.g. a class or function name)111 * @param string $context Optional context (e.g. a class or function name) 108 112 */ 109 113 public function log( $message, $context = '', $force = false ) { … … 111 115 112 116 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { 113 // phpcs:ignore QITStandard.PHP.DebugCode.DebugFunctionFound -- Intended debug logging when WP_DEBUG is enabled.117 // phpcs:ignore QITStandard.PHP.DebugCode.DebugFunctionFound, WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intended debug logging when WP_DEBUG is enabled. 114 118 error_log( $log_message ); 115 119 } -
woocommerce-services/trunk/classes/class-wc-connect-migration-survey.php
r3350055 r3732427 8 8 9 9 use Automattic\WCServices\Utils; 10 11 if ( ! defined( 'ABSPATH' ) ) { 12 exit; 13 } 10 14 11 15 if ( ! class_exists( 'WC_Connect_Migration_Survey' ) ) { … … 42 46 public function should_show_survey() { 43 47 // Force survey to show for testing/debugging 48 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only debug switch that only decides whether the survey is localized into the label script; nothing is written here and the survey's own submit/dismiss/track AJAX handlers each verify the wcs_migration_survey nonce. 44 49 if ( isset( $_GET['force_survey'] ) ) { 45 50 return true; … … 110 115 111 116 // Get survey data 112 $survey_data = json_decode( stripslashes( $_POST['survey_data'] ), true ); 117 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON payload; each value is sanitized below before use. 118 $survey_data = isset( $_POST['survey_data'] ) ? json_decode( wp_unslash( $_POST['survey_data'] ), true ) : null; 113 119 114 120 if ( ! $survey_data ) { -
woocommerce-services/trunk/classes/class-wc-connect-nux.php
r3685245 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Nux' ) ) { … … 18 22 */ 19 23 const SHOULD_SHOW_AFTER_CXN_BANNER = 'should_display_nux_after_jp_cxn_banner'; 24 25 /** 26 * Nonce action guarding the "accept the Terms of Service" link on the connection banner. 27 */ 28 const ACCEPT_TOS_NONCE_ACTION = 'wcs-nux-tos-accept'; 29 30 /** 31 * Nonce action guarding the "dismiss" link on the post-connection banner. 32 */ 33 const DISMISS_AFTER_CXN_BANNER_NONCE_ACTION = 'wcs-nux-notice-dismiss'; 20 34 21 35 /** … … 136 150 global $wpdb; 137 151 152 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Existence probe for one meta key across all posts; core has no API for "does any post carry this meta". The answer is memoised in the IS_NEW_LABEL_USER transient a few lines below, so this runs once per transient lifetime. 138 153 $results = $wpdb->get_results( 139 154 $wpdb->prepare( … … 175 190 '<h3>%s</h3><p>%s</p>', 176 191 __( 'Discounted Shipping Labels', 'woocommerce-services' ), 192 /* translators: %s: list of supported carriers */ 177 193 sprintf( __( "When you're ready, purchase and print discounted labels from %s right here.", 'woocommerce-services' ), implode( ' or ', $supported_carriers ) ) 178 194 ), … … 300 316 301 317 /** 318 * Whether the current user may accept the Terms of Service. 319 * 320 * Only the Jetpack connection owner may, or any user while Jetpack is in offline mode. 321 * This checks no capability, so in offline mode it is true for every user: callers must 322 * check capabilities themselves. 323 * 324 * @since 3.7.1 325 * 326 * @return bool 327 */ 328 public static function current_user_can_accept_tos() { 329 return WC_Connect_Jetpack::is_current_user_connection_owner() || WC_Connect_Jetpack::is_offline_mode(); 330 } 331 332 /** 302 333 * Whether the WooCommerce Tax connection banner may render on the given screen. 303 334 * … … 320 351 321 352 return 'woocommerce_page_wc-settings' === $screen->base 353 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen check: WooCommerce puts the settings tab in the URL, and this only decides whether the banner is rendered on it. No state is touched here. 322 354 && isset( $_GET['tab'] ) && 'tax' === $_GET['tab']; 323 355 } … … 385 417 // If this is the case, the admin can connect the site on their own, and should be able to use WCS as ususal 386 418 $jetpack_install_status = $this->get_jetpack_install_status(); 419 $can_accept_tos = self::current_user_can_accept_tos(); 387 420 388 421 $banner_to_display = self::get_banner_type_to_display( … … 390 423 'jetpack_connection_status' => $jetpack_install_status, 391 424 'tos_accepted' => WC_Connect_Options::get_option( 'tos_accepted' ), 392 'can_accept_tos' => WC_Connect_Jetpack::is_current_user_connection_owner() || WC_Connect_Jetpack::is_offline_mode(), 393 'should_display_after_cxn_banner' => WC_Connect_Options::get_option( self::SHOULD_SHOW_AFTER_CXN_BANNER ), 425 'can_accept_tos' => $can_accept_tos, 426 // The after-connection banner accepts the terms on render, so only a user who may accept gets it. 427 'should_display_after_cxn_banner' => $can_accept_tos && WC_Connect_Options::get_option( self::SHOULD_SHOW_AFTER_CXN_BANNER ), 394 428 'is_site_only_connection' => WC_Connect_Jetpack::is_site_only_connection(), 395 429 ) … … 518 552 } 519 553 554 /** 555 * Verifies the nonce carried by a banner link before it is allowed to change any state. 556 * 557 * The banner links are plain GET links rendered into an admin notice, so without this 558 * an attacker could make a logged-in admin accept the Terms of Service or dismiss the 559 * banner just by getting them to load a URL. 560 * 561 * @param string $action Nonce action the link was created with. 562 * @return bool True when the request carries a valid nonce for $action. 563 */ 564 private static function is_nux_action_verified( $action ) { 565 if ( empty( $_GET['_wpnonce'] ) ) { 566 return false; 567 } 568 569 return (bool) wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), $action ); 570 } 571 520 572 public function show_banner_after_connection() { 521 573 if ( ! $this->should_display_nux_notice_for_current_store_locale() ) { … … 528 580 529 581 // Did the user just dismiss? 530 if ( isset( $_GET['wcs-nux-notice'] ) && 'dismiss' === $_GET['wcs-nux-notice'] ) { 582 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The nonce is verified by self::is_nux_action_verified() on the next line before anything is written. 583 if ( isset( $_GET['wcs-nux-notice'] ) && 'dismiss' === $_GET['wcs-nux-notice'] 584 && self::is_nux_action_verified( self::DISMISS_AFTER_CXN_BANNER_NONCE_ACTION ) ) { 531 585 // No longer need to keep track of whether the before connection banner was displayed. 532 586 WC_Connect_Options::delete_option( self::SHOULD_SHOW_AFTER_CXN_BANNER ); 533 wp_safe_redirect( remove_query_arg( 'wcs-nux-notice') );587 wp_safe_redirect( remove_query_arg( array( 'wcs-nux-notice', '_wpnonce' ) ) ); 534 588 exit; 589 } 590 591 // Only a user who may accept the terms accepts them by seeing this banner. 592 if ( ! self::current_user_can_accept_tos() ) { 593 if ( ! WC_Connect_Options::get_option( 'tos_accepted' ) ) { 594 $this->show_tos_informational_banner(); 595 } 596 return; 535 597 } 536 598 … … 550 612 'description' => esc_html( sprintf( $description_base, $feature_list ) ), 551 613 'button_text' => __( 'Got it, thanks!', 'woocommerce-services' ), 552 'button_link' => add_query_arg( 553 array( 554 'wcs-nux-notice' => 'dismiss', 555 ) 614 'button_link' => wp_nonce_url( 615 add_query_arg( 616 array( 617 'wcs-nux-notice' => 'dismiss', 618 ) 619 ), 620 self::DISMISS_AFTER_CXN_BANNER_NONCE_ACTION 556 621 ), 557 622 'image_url' => plugins_url( … … 573 638 } 574 639 575 if ( isset( $_GET['wcs-nux-tos'] ) && 'accept' === $_GET['wcs-nux-tos'] ) { 640 // Only a user who may accept the terms gets the link that accepts them. 641 if ( ! self::current_user_can_accept_tos() ) { 642 if ( ! WC_Connect_Options::get_option( 'tos_accepted' ) ) { 643 $this->show_tos_informational_banner(); 644 } 645 return; 646 } 647 648 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The nonce is verified by self::is_nux_action_verified() on the next line before the Terms of Service acceptance is stored. 649 if ( isset( $_GET['wcs-nux-tos'] ) && 'accept' === $_GET['wcs-nux-tos'] 650 && self::is_nux_action_verified( self::ACCEPT_TOS_NONCE_ACTION ) ) { 576 651 WC_Connect_Options::update_option( 'tos_accepted', true ); 577 652 578 653 $this->tracks->opted_in( 'tos_banner' ); 579 654 580 wp_safe_redirect( remove_query_arg( 'wcs-nux-tos') );655 wp_safe_redirect( remove_query_arg( array( 'wcs-nux-tos', '_wpnonce' ) ) ); 581 656 exit; 582 657 } … … 592 667 'description' => esc_html( sprintf( $description_base, $feature_list ) ), 593 668 'button_text' => __( 'Connect', 'woocommerce-services' ), 594 'button_link' => add_query_arg( 595 array( 596 'wcs-nux-tos' => 'accept', 597 ) 669 'button_link' => wp_nonce_url( 670 add_query_arg( 671 array( 672 'wcs-nux-tos' => 'accept', 673 ) 674 ), 675 self::ACCEPT_TOS_NONCE_ACTION 598 676 ), 599 677 'image_url' => plugins_url( … … 653 731 <p class="wcs-nux__notice-content-tos"> 654 732 <?php 655 /* translators: %1$s example values include "Install Jetpack and CONNECT >", "Activate Jetpack and CONNECT >", "CONNECT >" */656 733 printf( 657 734 wp_kses( 735 /* translators: %1$s: button text, example values include "Install Jetpack and CONNECT >", "Activate Jetpack and CONNECT >", "CONNECT >", %2$s: Terms of Service URL, %3$s: Privacy Policy URL */ 658 736 __( 'By clicking "%1$s", you agree to our <a href="%2$s">Terms of Service</a> and have read our <a href="%3$s">Privacy Policy</a>.', 'woocommerce-services' ), 659 737 array( -
woocommerce-services/trunk/classes/class-wc-connect-options.php
r3452871 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Options' ) ) { … … 138 142 $names = (array) $names; 139 143 if ( ! self::is_valid( $names ) ) { 140 wp_trigger_error( 'WC_Connect_Options::delete_option', esc_html( sprintf( 'Invalid WooCommerce Tax option names: %s', print_r( $names, 1) ) ), E_USER_WARNING );144 wp_trigger_error( 'WC_Connect_Options::delete_option', esc_html( sprintf( 'Invalid WooCommerce Tax option names: %s', wp_json_encode( $names ) ) ), E_USER_WARNING ); 141 145 return false; 142 146 } … … 332 336 private static function delete_all_shipping_methods_options() { 333 337 global $wpdb; 338 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Needs the raw method_id/instance_id rows of every shipping zone method; WC_Shipping_Zones would instantiate each method object, which is exactly what must not happen while the plugin's options are being torn down. Runs once, on uninstall (WC_Connect_Loader::plugin_uninstall()), so there is nothing to cache. 334 339 $methods = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zone_methods " ); 335 340 -
woocommerce-services/trunk/classes/class-wc-connect-order-presenter.php
r2818899 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Order_Presenter' ) ) { -
woocommerce-services/trunk/classes/class-wc-connect-payment-gateway.php
r1586050 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Payment_Gateway' ) ) { … … 12 16 13 17 $this->init_settings(); 14 15 18 } 16 17 19 } 18 20 19 21 } 20 -
woocommerce-services/trunk/classes/class-wc-connect-payment-methods-store.php
r3097468 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Payment_Methods_Store' ) ) { … … 20 24 protected $logger; 21 25 22 public function __construct( WC_Connect_Service_Settings_Store $service_settings_store, 23 WC_Connect_API_Client $api_client, WC_Connect_Logger $logger ) { 26 public function __construct( 27 WC_Connect_Service_Settings_Store $service_settings_store, 28 WC_Connect_API_Client $api_client, 29 WC_Connect_Logger $logger 30 ) { 24 31 25 32 $this->service_settings_store = $service_settings_store; 26 33 $this->api_client = $api_client; 27 34 $this->logger = $logger; 28 29 35 } 30 36 -
woocommerce-services/trunk/classes/class-wc-connect-paypal-ec.php
r3289651 r3732427 135 135 sprintf( 136 136 wp_kses( 137 /* translators: %s: PayPal signup URL */ 137 138 __( 'To issue refunds via PayPal Checkout, you will need to <a href="%s">link a PayPal account</a> with the email address that received this payment.', 'woocommerce-services' ), 138 139 array( 'a' => array( 'href' => array() ) ) … … 194 195 || ( // WooCommerce » Settings » Payments. 195 196 'woocommerce_page_wc-settings' === $screen->base 197 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen check: WooCommerce puts its settings tab in the URL, and this only decides whether the PayPal banner is enqueued. Nothing is written. 196 198 && isset( $_GET['tab'] ) && 'checkout' === $_GET['tab'] 197 199 ) 198 200 || ( // WooCommerce » Extensions » Payments. 199 201 'woocommerce_page_wc-addons' === $screen->base 202 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen check, as above: the section query arg is WooCommerce's own and only selects where the banner renders. 200 203 && isset( $_GET['section'] ) && 'payment-gateways' === $_GET['section'] 201 204 ) … … 267 270 268 271 // Prevent user from changing Payment Action away from "Sale", the only option for which payments will work 269 $form_fields['paymentaction']['disabled'] = true; 272 $form_fields['paymentaction']['disabled'] = true; 273 /* translators: %s: existing description of the payment action setting */ 270 274 $form_fields['paymentaction']['description'] = sprintf( __( '%s (Note that "authorizing payment only" requires linking a PayPal account.)', 'woocommerce-services' ), $form_fields['paymentaction']['description'] ); 271 275 272 276 // Communicate WCS proxying and provide option to disable 273 $reset_link = add_query_arg(277 $reset_link = add_query_arg( 274 278 array( 275 279 'reroute_requests' => 'no', … … 278 282 wc_gateway_ppec()->get_admin_setting_link() 279 283 ); 284 /* translators: %s: URL that resets the PayPal API credentials */ 280 285 $api_creds_template = __( 'Payments will be authenticated by WooCommerce Tax and directed to the following email address. To disable this feature and link a PayPal account, <a href="%s">click here</a>.', 'woocommerce-services' ); 281 286 if ( empty( $settings->api_username ) ) { … … 291 296 } else { 292 297 // Provide option to enable request proxying 293 $reset_link = add_query_arg(298 $reset_link = add_query_arg( 294 299 array( 295 300 'reroute_requests' => 'yes', … … 298 303 wc_gateway_ppec()->get_admin_setting_link() 299 304 ); 305 /* translators: %s: URL that resets the PayPal API credentials */ 300 306 $api_creds_template = __( 'To authenticate payments with WooCommerce Tax, <a href="%s">click here</a>.', 'woocommerce-services' ); 301 307 if ( empty( $settings->api_username ) ) { … … 339 345 empty( $_GET['reroute_requests'] ) || 340 346 empty( $_GET['nonce'] ) || 341 ! wp_verify_nonce( $_GET['nonce'], 'reroute_requests' ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized347 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['nonce'] ) ), 'reroute_requests' ) 342 348 ) { 343 349 return; -
woocommerce-services/trunk/classes/class-wc-connect-privacy.php
r3289651 r3732427 38 38 sprintf( 39 39 wp_kses( 40 /* translators: %s: URL of the privacy documentation */ 40 41 __( 'By using this extension, you may be storing personal data or sharing data with external services. <a href="%s" target="_blank">Learn more about how this works, including what you may want to include in your privacy policy.</a>', 'woocommerce-services' ), 41 42 array( -
woocommerce-services/trunk/classes/class-wc-connect-service-schemas-store.php
r3289651 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Service_Schemas_Store' ) ) { … … 209 213 public function get_service_schema_by_instance_id( $instance_id ) { 210 214 global $wpdb; 215 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Only the method_id string is wanted; WC_Shipping_Zones::get_shipping_method() would instantiate the shipping method, and this runs from inside shipping method code, so that would recurse. Caching is skipped because a zone edit can repoint an instance_id at a different method and WooCommerce fires no hook this store could invalidate on. 211 216 $method_id = $wpdb->get_var( 212 217 $wpdb->prepare( -
woocommerce-services/trunk/classes/class-wc-connect-service-schemas-validator.php
r2498257 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Service_Schemas_Validator' ) ) { … … 45 49 } 46 50 47 $service_counter ++;51 ++$service_counter; 48 52 } 49 53 … … 190 194 return true; 191 195 } 192 193 196 } 194 197 -
woocommerce-services/trunk/classes/class-wc-connect-service-settings-store.php
r3327549 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Service_Settings_Store' ) ) { … … 262 266 * Updates the existing label data 263 267 * 264 * @param $order_id 265 * @param $new_label_data 266 * 267 * @return array updated label info 268 * @param int $order_id Order ID. 269 * @param object $new_label_data Label data from the Connect server, with a label_id. 270 * 271 * @return array|object The updated label info, or $new_label_data unchanged when the order 272 * does not have that label. 268 273 */ 269 274 public function update_label_order_meta_data( $order_id, $new_label_data ) { 270 $result = $new_label_data; 271 $order = wc_get_order( $order_id ); 275 $result = $new_label_data; 276 $order = wc_get_order( $order_id ); 277 278 // Nothing to update when the ID is not an order. 279 if ( ! $order instanceof WC_Order ) { 280 return $result; 281 } 282 272 283 $labels_data = $this->get_label_order_meta_data( $order_id ); 284 $matched = false; 273 285 foreach ( $labels_data as $index => $label_data ) { 274 if ( $label_data['label_id'] === $new_label_data->label_id ) { 286 if ( is_array( $label_data ) && isset( $label_data['label_id'] ) && (int) $label_data['label_id'] === (int) $new_label_data->label_id ) { 287 $matched = true; 275 288 $result = array_merge( $label_data, (array) $new_label_data ); 276 289 $labels_data[ $index ] = $result; … … 282 295 } 283 296 } 297 298 // Not this order's label: write nothing, not even an empty list, which counts as a label. 299 if ( ! $matched ) { 300 return $result; 301 } 302 284 303 $order->update_meta_data( 'wc_connect_labels', $labels_data ); 285 304 $order->save(); … … 291 310 * 292 311 * @param $order_id 293 * @param array $new_labels - labels to be added312 * @param array $new_labels - labels to be added 294 313 */ 295 314 public function add_labels_to_order( $order_id, $new_labels ) { 315 $order = wc_get_order( $order_id ); 316 317 // Nothing to add to when the ID is not an order. 318 if ( ! $order instanceof WC_Order ) { 319 return; 320 } 321 296 322 $labels_data = $this->get_label_order_meta_data( $order_id ); 297 323 $labels_data = array_merge( $new_labels, $labels_data ); 298 $order = wc_get_order( $order_id );299 324 $order->update_meta_data( 'wc_connect_labels', $labels_data ); 300 325 $order->save(); … … 306 331 307 332 public function update_destination_address( $order_id, $api_address ) { 308 $order = wc_get_order( $order_id ); 333 $order = wc_get_order( $order_id ); 334 335 // Nothing to update when the ID is not an order. 336 if ( ! $order instanceof WC_Order ) { 337 return; 338 } 339 309 340 $wc_address = $order->get_address( 'shipping' ); 310 341 … … 361 392 362 393 public function get_enabled_services_by_ids( $service_ids ) { 363 if ( empty( $service_ids ) ) {394 if ( empty( $service_ids ) || ! is_array( $service_ids ) ) { 364 395 return array(); 365 396 } … … 367 398 $enabled_services = array(); 368 399 369 // Note: We use esc_sql here instead of prepare because we are using WHERE IN 370 // https://codex.wordpress.org/Function_Reference/esc_sql. 371 372 $escaped_list = ''; 373 foreach ( $service_ids as $shipping_service ) { 374 if ( ! empty( $escaped_list ) ) { 375 $escaped_list .= ','; 376 } 377 $escaped_list .= "'" . esc_sql( $shipping_service ) . "'"; 378 } 400 // prepare() cannot take a list, but it can take a generated run of placeholders: 401 // one %s per service id, with the ids passed through as the arguments. 402 $service_ids = array_values( $service_ids ); 403 $placeholders = implode( ', ', array_fill( 0, count( $service_ids ), '%s' ) ); 379 404 380 405 global $wpdb; 381 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared --- Need to use interpolated for the `IN()` condition 406 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- The only interpolations are $wpdb->prefix and $placeholders, a generated run of %s built above from count( $service_ids ); the ids themselves are bound by prepare(). 407 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Joins WooCommerce's shipping zone tables, which have no core reader that returns raw rows; WC_Shipping_Zones would instantiate every shipping method. Not cached because zone methods are edited from WooCommerce's own screens, which fire no hook this store could invalidate on. 382 408 $methods = $wpdb->get_results( 383 "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zone_methods " . 384 "LEFT JOIN {$wpdb->prefix}woocommerce_shipping_zones " . 385 "ON {$wpdb->prefix}woocommerce_shipping_zone_methods.zone_id = {$wpdb->prefix}woocommerce_shipping_zones.zone_id " . 386 "WHERE method_id IN ({$escaped_list}) " . 387 'ORDER BY zone_order, instance_id;' 409 $wpdb->prepare( 410 "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zone_methods " . 411 "LEFT JOIN {$wpdb->prefix}woocommerce_shipping_zones " . 412 "ON {$wpdb->prefix}woocommerce_shipping_zone_methods.zone_id = {$wpdb->prefix}woocommerce_shipping_zones.zone_id " . 413 "WHERE method_id IN ({$placeholders}) " . 414 'ORDER BY zone_order, instance_id;', 415 $service_ids 416 ) 388 417 ); 389 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared 418 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare 390 419 391 420 if ( empty( $methods ) ) { … … 445 474 $new_method_id = $service_schema->method_id; 446 475 476 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Rewrites the stored method_id of a zone method row; core exposes no API for renaming a shipping method in place. One-shot migration guarded by the shipping_methods_migrated option, and a write has nothing to cache. 447 477 $wpdb->update( 448 478 "{$wpdb->prefix}woocommerce_shipping_zone_methods", -
woocommerce-services/trunk/classes/class-wc-connect-settings-pages.php
r3310537 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Settings_Pages' ) ) { … … 115 119 } 116 120 121 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: the order id only becomes a "back to order" link in the settings screen's bootstrap. This renders a screen, it does not handle a submission, so there is no nonce to verify. 117 122 if ( isset( $_GET['from_order'] ) ) { 118 $from_order = sanitize_text_field( $_GET['from_order'] ); 123 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Same read-only screen argument as the isset() above. 124 $from_order = sanitize_text_field( wp_unslash( $_GET['from_order'] ) ); 119 125 $extra_args['order_id'] = $from_order; 120 126 $extra_args['order_href'] = get_edit_post_link( $from_order ); 121 127 } 122 128 129 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: picks which carrier's section of the settings screen to render. No state is changed and no form is being processed. 123 130 if ( ! empty( $_GET['carrier'] ) ) { 124 $carrier = sanitize_text_field( $_GET['carrier'] ); 131 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Same read-only screen argument as the empty() check above. 132 $carrier = sanitize_text_field( wp_unslash( $_GET['carrier'] ) ); 125 133 $extra_args['carrier'] = $carrier; 126 134 $extra_args['continents'] = $this->continents->get(); -
woocommerce-services/trunk/classes/class-wc-connect-shipping-label.php
r3350055 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Shipping_Label' ) ) { -
woocommerce-services/trunk/classes/class-wc-connect-shipping-method.php
r3327549 r3732427 1 1 <?php 2 3 if ( ! defined( 'ABSPATH' ) ) { 4 exit; 5 } 2 6 3 7 if ( ! class_exists( 'WC_Connect_Shipping_Method' ) ) { -
woocommerce-services/trunk/classes/class-wc-connect-taxjar-integration.php
r3679638 r3732427 3 3 use Automattic\WCServices\StoreNotices\StoreNoticesNotifier; 4 4 use Automattic\WCServices\Tax\Address; 5 use Automattic\WCServices\Tax\StoreAddressNotice; 6 use Automattic\WCServices\Tax\StoreAddressVerifier; 5 7 6 8 class WC_Connect_TaxJar_Integration { … … 17 19 18 20 /** 19 * @var StoreNoticesNotifier 21 * Shows customer-input errors at checkout. Optional: null when none is passed to the constructor. 22 * 23 * @var StoreNoticesNotifier|null 20 24 */ 21 25 private $notifier; … … 68 72 69 73 /** 74 * Rate ids TaxJar returned for an admin recalculation, keyed by order id, then by 75 * order item id. Kept apart from $response_rate_ids, which other code reads as 76 * "this request is the cart flow". Each entry is used once, see 77 * take_admin_recalculation_rate_ids(). 78 * 79 * @var array<int, array<int, int[]>> 80 */ 81 private $admin_recalculation_rate_ids = array(); 82 83 /** 70 84 * Tax snapshots captured before an out-of-cart recalculation, keyed by order id, 71 85 * so they can be restored after WC recalculates the order totals. … … 74 88 */ 75 89 private $pre_recalculation_tax_snapshots = array(); 90 91 /** 92 * Taxable amounts of order items as they were before an in-request save changed 93 * them, keyed by item id. The REST API saves an edited item before it 94 * recalculates the order, so this is the only place the old amount is visible. 95 * 96 * @var array 97 */ 98 private $order_item_base_before_save = array(); 99 100 /** 101 * Billing and shipping addresses of orders as they were before an in-request save 102 * changed them, keyed by order id. Code that saves an order before recalculating it 103 * would otherwise leave no trace of the old address. 104 * 105 * @var array 106 */ 107 private $order_address_before_save = array(); 108 109 /** 110 * The location WooCommerce taxed each order item for in this recalculation, keyed by 111 * spl_object_id(), as it passed it to the item's after-calculate-taxes hook. Only kept 112 * for orders being preserved, so the hook can run again on the tax set here. 113 * 114 * @var array<int, array> 115 */ 116 private $order_item_tax_locations = array(); 117 118 /** 119 * Order items being created, like $orders_created_in_request. WC_Order::add_product() 120 * saves the item at once, so it is already saved when the order is recalculated. 121 * 122 * @var array{objects: array<int, bool>, ids: array<int, bool>} 123 */ 124 private $order_items_created_in_request = array( 125 'objects' => array(), 126 'ids' => array(), 127 ); 128 129 /** 130 * Orders being created: spl_object_id() of each order object saved without an id, 131 * then the id it was given. A new order had no earlier address its tax was based on. 132 * 133 * @var array{objects: array<int, bool>, ids: array<int, bool>} 134 */ 135 private $orders_created_in_request = array( 136 'objects' => array(), 137 'ids' => array(), 138 ); 76 139 77 140 /** … … 108 171 const OPTION_NAME = 'wc_connect_taxes_enabled'; 109 172 const SETUP_WIZARD_OPTION_NAME = 'woocommerce_setup_automated_taxes'; 173 174 /** 175 * Returned by lookup_order_taxes() when it failed because the order's address 176 * cannot be sent to TaxJar, as opposed to TaxJar failing to answer. 177 */ 178 private const ORDER_TAX_LOOKUP_BAD_ADDRESS = 'bad_address'; 179 180 /** 181 * Returned by lookup_order_taxes() when it failed because the store's own address 182 * cannot be sent to TaxJar. 183 */ 184 private const ORDER_TAX_LOOKUP_BAD_STORE_ADDRESS = 'bad_store_address'; 185 186 /** 187 * WooCommerce's after-calculate-taxes hook for each order item type. 188 */ 189 private const ORDER_ITEM_TAX_HOOKS = array( 190 'line_item' => 'woocommerce_order_item_after_calculate_taxes', 191 'fee' => 'woocommerce_order_item_fee_after_calculate_taxes', 192 'shipping' => 'woocommerce_order_item_shipping_after_calculate_taxes', 193 ); 110 194 111 195 /** … … 182 266 } 183 267 268 /** 269 * Name of the 0% row that keeps a merchant's state-wide rate from applying to a looked-up town. 270 * 271 * The name is stored with the row and copied to any order it taxes, so it has to say 272 * why the merchant's own rate does not apply there. US rows carry the same county and 273 * city prefix as the rows TaxJar's rates are stored in. 274 * 275 * @param string $to_country The destination country. 276 * @param array $jurisdictions Tax jurisdictions. 277 * 278 * @return string 279 */ 280 private static function generate_nullified_rate_name( string $to_country, array $jurisdictions ) { 281 /* translators: Name of a 0% tax rate added when automated taxes replace a store's manual rate for one town. */ 282 $label = __( 'Manual Rate Nullified (Automated Taxes)', 'woocommerce-services' ); 283 284 if ( 'US' !== $to_country ) { 285 return $label; 286 } 287 288 $county = isset( $jurisdictions['county'] ) ? trim( (string) $jurisdictions['county'] ) : ''; 289 $city = isset( $jurisdictions['city'] ) ? trim( (string) $jurisdictions['city'] ) : ''; 290 $jurisdiction = trim( $county . ' ' . $city ); 291 292 return ( '' !== $jurisdiction ? $jurisdiction . ' : ' : '' ) . $label; 293 } 294 184 295 public function init() { 185 296 // Only enable WCS TaxJar integration if the official TaxJar plugin isn't active. … … 226 337 $this->configure_tax_settings(); 227 338 339 // Check the store address with TaxJar when it changes, and tell the merchant when it looks wrong. 340 $store_address_verifier = new StoreAddressVerifier( $this->api_client, $this ); 341 $store_address_verifier->init(); 342 ( new StoreAddressNotice( $store_address_verifier ) )->init(); 343 228 344 // Calculate Taxes at Cart / Checkout 229 345 if ( class_exists( 'WC_Cart_Totals' ) ) { // Woo 3.2+ … … 238 354 // Preserve recorded taxes when an existing order is recalculated outside the 239 355 // cart/checkout and admin flows (e.g. a REST API or programmatic order update), 240 // so a changed address does not wipe the stored tax lines to zero. 356 // so a changed address does not wipe the stored tax lines to zero. When the 357 // order's amounts change, the rates recorded on the order are re-applied. When 358 // its address changes, or a new order has no tax yet, TaxJar is asked for rates. 241 359 add_action( 'woocommerce_order_before_calculate_taxes', array( $this, 'preserve_order_taxes_on_recalculation' ), 10, 2 ); 360 foreach ( self::ORDER_ITEM_TAX_HOOKS as $after_calculate_taxes ) { 361 add_action( $after_calculate_taxes, array( $this, 'remember_order_item_tax_location' ), PHP_INT_MIN, 2 ); 362 } 363 add_action( 'woocommerce_before_order_item_object_save', array( $this, 'remember_order_item_base_before_save' ), 10, 1 ); 364 add_action( 'woocommerce_after_order_item_object_save', array( $this, 'remember_order_item_created' ), 10, 1 ); 365 add_action( 'woocommerce_before_order_object_save', array( $this, 'remember_order_before_save' ), 10, 1 ); 366 add_action( 'woocommerce_after_order_object_save', array( $this, 'remember_order_created' ), 10, 1 ); 242 367 243 368 // Set customer taxable location for local pickup … … 284 409 $backedup_tax_rates_url = admin_url( '/admin.php?page=wc-status&tab=connect#tax-rate-backups' ); 285 410 411 /* translators: %1$s: opening link tag, %2$s: closing link tag */ 286 412 $powered_by_wct_notice = '<p>' . sprintf( __( 'Automated taxes take over from the WooCommerce core tax settings. This means that "Display prices" will be set to Excluding tax and tax will be Calculated using Customer shipping address. %1$sLearn more about Automated taxes here.%2$s', 'woocommerce-services' ), '<a href="https://woocommerce.com/document/woocommerce-shipping-and-tax/woocommerce-tax/#setup-and-configuration">', '</a>' ) . '</p>'; 287 413 414 /* translators: %1$s: opening link tag, %2$s: closing link tag */ 288 415 $backup_notice = ( ! empty( WC_Connect_Functions::get_backed_up_tax_rate_files() ) ) ? '<p>' . sprintf( __( 'Your previous tax rates were backed up and can be downloaded %1$shere%2$s.', 'woocommerce-services' ), '<a href="' . esc_url( $backedup_tax_rates_url ) . '">', '</a>' ) . '</p>' : ''; 289 416 290 417 $desctructive_action_notice = '<p>' . __( 'Enabling this option overrides any tax rates you have manually added.', 'woocommerce-services' ) . '</p>'; 418 /* translators: %1$s: opening link tag, %2$s: closing link tag */ 291 419 $desctructive_backup_notice = '<p>' . sprintf( __( 'Your existing tax rates will be backed-up to a CSV that you can download %1$shere%2$s.', 'woocommerce-services' ), '<a href="' . esc_url( $backedup_tax_rates_url ) . '">', '</a>' ) . '</p>'; 292 420 … … 357 485 public function fix_tooltip_keepalive() { 358 486 global $pagenow; 487 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen check on WooCommerce's own page/tab/section query args; it only decides whether the tooltip's inline script is added on WooCommerce » Settings » Tax. Nothing is written. 359 488 if ( 'admin.php' !== $pagenow || ! isset( $_GET['page'] ) || 'wc-settings' !== $_GET['page'] || ! isset( $_GET['tab'] ) || 'tax' !== $_GET['tab'] || ! empty( $_GET['section'] ) ) { 360 489 return; … … 509 638 $formatted_message = is_scalar( $message ) ? $message : json_encode( $message ); 510 639 511 // ignore error messages caused by customer input 640 // Show errors caused by customer input to the customer through the notifier, when there is 641 // one, instead of logging them. Only where there is a customer to show them to: REST, cron 642 // and WP-CLI requests have no WC session, so those errors are logged. 512 643 $state_zip_mismatch = false !== strpos( $formatted_message, 'to_zip' ) && false !== strpos( $formatted_message, 'is not used within to_state' ); 513 644 $invalid_postcode = false !== strpos( $formatted_message, 'isn\'t a valid postal code for' ); 514 $malformed_postcode = false !== strpos( $formatted_message, 'zip code has incorrect format' ); 515 if ( ! is_admin() && ( $state_zip_mismatch || $invalid_postcode || $malformed_postcode ) ) { 645 // Only the shopper's ZIP. A bad store ZIP is the merchant's to fix, so it is logged and checkout goes on. 646 $malformed_postcode = false !== strpos( $formatted_message, 'Country destination is set to US but the zip code has incorrect format' ); 647 if ( ! is_admin() && StoreNoticesNotifier::wc_session_exists() && ( $state_zip_mismatch || $invalid_postcode || $malformed_postcode ) ) { 648 if ( ! $this->notifier ) { 649 // The notifier is optional. Without one, log the error, but through log() rather 650 // than error(): customer input must not replace the admin error notice. 651 $this->logger->log( $formatted_message, 'WCS Tax' ); 652 653 return; 654 } 655 516 656 $fields = WC()->countries->get_address_fields(); 517 657 $postcode_field_name = __( 'ZIP/Postal code', 'woocommerce-services' ); … … 521 661 522 662 if ( $state_zip_mismatch ) { 663 /* translators: %s: ZIP/Postal code checkout field label */ 523 664 $message = sprintf( _x( '%s does not match the selected state.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name ); 524 665 } elseif ( $malformed_postcode ) { 666 /* translators: %s: ZIP/Postal code checkout field label */ 525 667 $message = sprintf( _x( '%s is not formatted correctly.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name ); 526 668 } else { 669 /* translators: %s: ZIP/Postal code checkout field label */ 527 670 $message = sprintf( _x( 'Invalid %s entered.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name ); 528 671 } … … 653 796 * Calculate tax / totals using TaxJar for backend orders 654 797 * 655 * Unchanged fromthe TaxJar plugin.798 * Based on the TaxJar plugin. 656 799 * See: https://github.com/taxjar/taxjar-woocommerce-plugin/blob/96b5d57/includes/class-wc-taxjar-integration.php#L557 657 800 * … … 660 803 public function calculate_backend_totals( $order_id ) { 661 804 $order = wc_get_order( $order_id ); 662 $address = $this-> get_backend_address();805 $address = $this->fill_missing_backend_street( $this->get_backend_address(), $order ); 663 806 $line_items = $this->get_backend_line_items( $order ); 664 807 if ( method_exists( $order, 'get_shipping_total' ) ) { … … 678 821 ) 679 822 ); 680 if ( class_exists( 'WC_Order_Item_Tax' ) ) { // Add tax rates manually for Woo 3.0+ 681 /** 682 * @var WC_Order_Item_Product $item Product Order Item. 683 */ 684 foreach ( $order->get_items() as $item_key => $item ) { 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 ] ) ) { 689 $rate_id = $taxes['rate_ids'][ $line_item_key ]; 690 $item_tax = new WC_Order_Item_Tax(); 691 $item_tax->set_rate( $rate_id ); 692 $item_tax->set_order_id( $order_id ); 693 $item_tax->save(); 823 824 /* 825 * WooCommerce recalculates the order after this hook by reading the rate table 826 * for the address. That can return more than TaxJar did: a merchant's row at a 827 * priority none of the returned components uses stacks on top of them. Keep the 828 * rate ids TaxJar returned per order item so override_order_item_taxes() can 829 * apply exactly those, as checkout does. Without an answer, WooCommerce's 830 * result stands, as before. 831 */ 832 $this->admin_recalculation_rate_ids[ (int) $order_id ] = array(); 833 if ( is_array( $taxes ) && ! empty( $taxes['rate_ids'] ) ) { 834 foreach ( $line_items as $item_key => $line_item ) { 835 $rate_ids = $taxes['rate_ids'][ $line_item['id'] ?? '' ] ?? null; 836 if ( is_array( $rate_ids ) && $rate_ids ) { 837 $this->admin_recalculation_rate_ids[ (int) $order_id ][ (int) $item_key ] = $rate_ids; 694 838 } 695 839 } 696 } elseif ( class_exists( 'WC_AJAX' ) ) { // Recalculate tax for Woo 2.6 to apply new tax rates 697 remove_action( 'woocommerce_before_save_order_items', array( $this, 'calculate_backend_totals' ), 20 ); 698 if ( check_ajax_referer( 'calc-totals', 'security', false ) ) { 699 WC_AJAX::calc_line_taxes(); 700 } 701 add_action( 'woocommerce_before_save_order_items', array( $this, 'calculate_backend_totals' ), 20 ); 702 } 840 } 841 842 /* 843 * No tax items are saved here. wc_save_order_items(), which fires this hook, 844 * rebuilds the order's tax items from its line items right after, and 845 * Recalculate then recalculates them, so WooCommerce adds one per rate the 846 * order is charged under that rate's own name. 847 */ 703 848 } 704 849 … … 969 1114 970 1115 /** 1116 * Fill in the street when an order-items request arrived without one. 1117 * 1118 * Core's Recalculate button and line-item delete both post country, state, postcode 1119 * and city, and the order-screen script (`client/new-order-taxjar.js`) adds `street` 1120 * to each. When that script did not run (not loaded, stopped by another script's 1121 * error, or another handler replaced the request data), the key is missing and 1122 * TaxJar would get no street. 1123 * 1124 * The order's saved street is used only when the posted address matches the saved 1125 * address on the side core read it from. Otherwise the merchant edited the address 1126 * without saving the order, and pairing the new ZIP with the old street would be 1127 * worse than sending no street. A street posted empty is the merchant's input and is 1128 * left alone. 1129 * 1130 * @param array|mixed $address Backend address in the `to_*` shape from `get_backend_address()`. 1131 * @param WC_Order|bool $order Order being recalculated. 1132 * @return array|mixed The address, with `to_street` filled when it was safe to. 1133 */ 1134 private function fill_missing_backend_street( $address, $order ) { 1135 // Core verified the nonce and capability before woocommerce_before_save_order_items. 1136 if ( isset( $_POST['street'] ) || ! is_array( $address ) || ! empty( $address['to_street'] ) || ! $order instanceof WC_Order ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing 1137 return $address; 1138 } 1139 1140 $posted = Address::from_options( $address ); 1141 if ( '' === $posted->country() ) { 1142 return $address; 1143 } 1144 1145 // Core picks the side from the shipping country field as it stands in the form; 1146 // we only have the saved one. They differ if the merchant blanks or fills in that 1147 // field without saving, and when billing and shipping share a town the four posted 1148 // fields can't tell us which side they came from, so we can pair a billing 1149 // address with the shipping street. We still guess rather than send no street, 1150 // since a missing street is the bug this fallback exists to avoid. 1151 $type = ( 'shipping' === get_option( 'woocommerce_tax_based_on' ) && '' !== (string) $order->get_shipping_country() ) ? 'shipping' : 'billing'; 1152 $saved = Address::from_order( $order, $type ); 1153 1154 if ( '' === $saved->street() 1155 || $saved->country() !== $posted->country() 1156 || $saved->state() !== $posted->state() 1157 || $saved->postcode() !== $posted->postcode() 1158 || $saved->city() !== $posted->city() 1159 ) { 1160 $this->_log( 'Order items were saved without a street and the posted address does not match the saved ' . $type . ' address; calculating without a street.' ); 1161 return $address; 1162 } 1163 1164 $this->_log( 'Order items were saved without a street; using the saved ' . $type . ' street.' ); 1165 $address['to_street'] = $saved->street(); 1166 1167 return $address; 1168 } 1169 1170 /** 1171 * Read the TaxJar product tax code encoded in a tax class slug. 1172 * 1173 * Stores map a class to a TaxJar category by ending its name with the numeric 1174 * code, so "Digital Goods 31000" slugs to `digital-goods-31000`. The code is the 1175 * last segment, whatever the class name's word count. Both line item builders go 1176 * through here so checkout and an admin order recalculation price the same code. 1177 * 1178 * @param string $tax_class Tax class slug ('' for Standard). 1179 * 1180 * @return string The code, or '' if the slug does not end in a number. 1181 */ 1182 private static function get_product_tax_code_from_tax_class( $tax_class ) { 1183 $segments = explode( '-', (string) $tax_class ); 1184 $code = end( $segments ); 1185 1186 return is_numeric( $code ) ? $code : ''; 1187 } 1188 1189 /** 971 1190 * Get line items at checkout 972 1191 * … … 991 1210 $line_subtotal = wc_format_decimal( $cart_item['line_subtotal'] ); 992 1211 $discount = wc_format_decimal( $cart_item['line_subtotal'] - $cart_item['line_total'] ); 993 $tax_class = explode( '-', $product->get_tax_class() ); 994 $tax_code = ''; 995 996 if ( isset( $tax_class ) && is_numeric( end( $tax_class ) ) ) { 997 $tax_code = end( $tax_class ); 998 } 1212 $tax_code = self::get_product_tax_code_from_tax_class( $product->get_tax_class() ); 999 1213 1000 1214 if ( 'shipping' !== $product->get_tax_status() && ( ! $product->is_taxable() || 'zero-rate' == sanitize_title( $product->get_tax_class() ) ) ) { … … 1114 1328 } 1115 1329 $this->backend_tax_classes[ $id ] = $tax_class_name; 1116 $tax_class = explode( '-', $tax_class_name ); 1117 $tax_code = ''; 1118 if ( isset( $tax_class[1] ) && is_numeric( $tax_class[1] ) ) { 1119 $tax_code = $tax_class[1]; 1120 } 1330 $tax_code = self::get_product_tax_code_from_tax_class( $tax_class_name ); 1121 1331 if ( 'taxable' !== $tax_status ) { 1122 1332 $tax_code = '99999'; … … 1126 1336 // matching note in get_line_items() for why this is recorded, not derived. 1127 1337 $this->non_taxable_line_items[ $id . '-' . $item_key ] = true; 1338 } elseif ( 'zero-rate' === sanitize_title( $tax_class_name ) ) { 1339 // Checkout sends the built-in Zero rate class as exempt too, and does not 1340 // record it: the 0% TaxJar returns belongs in that class's rate row. 1341 $tax_code = '99999'; 1128 1342 } 1129 1343 … … 1449 1663 */ 1450 1664 public function override_order_item_taxes( $item, $calculate_tax_for ) { 1665 // An admin recalculation looked this item up in calculate_backend_totals(). 1666 $admin_rate_ids = $this->take_admin_recalculation_rate_ids( $item ); 1667 if ( null !== $admin_rate_ids ) { 1668 $this->set_order_item_taxes_from_rate_ids( $item, $admin_rate_ids ); 1669 return; 1670 } 1671 1451 1672 // Only act if we have TaxJar-calculated rate IDs from this request. 1452 1673 if ( empty( $this->response_rate_ids ) || ! is_array( $this->response_rate_ids ) ) { … … 1480 1701 } 1481 1702 1703 $this->set_order_item_taxes_from_rate_ids( $item, $matching_rate_ids ); 1704 } 1705 1706 /** 1707 * Take the rate ids an admin recalculation looked up for an order item. 1708 * 1709 * Each entry is removed as it is read, so it applies to the recalculation it was 1710 * looked up for and not to a later one in the same request. 1711 * 1712 * @param WC_Order_Item $item The order item being taxed. 1713 * @return int[]|null Rate ids, or null when the item was not looked up. 1714 */ 1715 private function take_admin_recalculation_rate_ids( $item ) { 1716 if ( ! ( $item instanceof \WC_Order_Item_Product ) ) { 1717 return null; 1718 } 1719 1720 $order_id = (int) $item->get_order_id(); 1721 $item_id = (int) $item->get_id(); 1722 1723 if ( ! isset( $this->admin_recalculation_rate_ids[ $order_id ][ $item_id ] ) ) { 1724 return null; 1725 } 1726 1727 $rate_ids = $this->admin_recalculation_rate_ids[ $order_id ][ $item_id ]; 1728 unset( $this->admin_recalculation_rate_ids[ $order_id ][ $item_id ] ); 1729 1730 return $rate_ids; 1731 } 1732 1733 /** 1734 * Tax an order item at the given rate rows, replacing what WooCommerce found. 1735 * 1736 * @param WC_Order_Item_Product $item The order item. 1737 * @param array $rate_ids Tax rate ids. 1738 */ 1739 private function set_order_item_taxes_from_rate_ids( $item, array $rate_ids ) { 1482 1740 // Build tax rates array from the stored rate IDs. 1483 1741 $tax_rates = array(); 1484 foreach ( $ matching_rate_ids as $rate_id ) {1742 foreach ( $rate_ids as $rate_id ) { 1485 1743 $rate_id = absint( $rate_id ); 1486 1744 if ( ! $rate_id ) { … … 1723 1981 1724 1982 if ( ! is_array( $address ) ) { 1725 $this->logger->error( 'Nexus Address ERRORS: Nexus addresses has invalid format' . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . print_r( $address, true), 'WCS Tax' );1983 $this->logger->error( 'Nexus Address ERRORS: Nexus addresses has invalid format' . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . wp_json_encode( $address, JSON_PARTIAL_OUTPUT_ON_ERROR ), 'WCS Tax' ); 1726 1984 1727 1985 return false; … … 1745 2003 1746 2004 if ( null !== $value && ( ! is_scalar( $value ) || is_bool( $value ) ) ) { 1747 // wp_json_encode() returns false for unencodable payloads, which is 1748 // what this branch catches; fall back so the log keeps the address. 1749 $encoded = wp_json_encode( $address ); 1750 $this->logger->error( 'Nexus Address ERRORS: [' . $field . '] field must be a string' . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . ( false === $encoded ? print_r( $address, true ) : $encoded ), 'WCS Tax' ); 2005 // This branch catches payloads wp_json_encode() cannot fully encode, so ask 2006 // for partial output to keep the rest of the address in the log. 2007 $this->logger->error( 'Nexus Address ERRORS: [' . $field . '] field must be a string' . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . wp_json_encode( $address, JSON_PARTIAL_OUTPUT_ON_ERROR ), 'WCS Tax' ); 1751 2008 1752 2009 return false; … … 1784 2041 $errors = str_replace( '[postcode]', '[zip]', $errors ); 1785 2042 1786 $this->logger->error( 'Nexus Address ERRORS: ' . implode( ', ', $errors ) . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . print_r( $address, true), 'WCS Tax' );2043 $this->logger->error( 'Nexus Address ERRORS: ' . implode( ', ', $errors ) . PHP_EOL . 'Nexus address removed from request body.' . PHP_EOL . wp_json_encode( $address, JSON_PARTIAL_OUTPUT_ON_ERROR ), 'WCS Tax' ); 1787 2044 1788 2045 return false; … … 1828 2085 ( empty( $destination->postcode() ) && ! in_array( $destination->country(), WC()->countries->get_vat_countries(), true ) ) || 1829 2086 ( empty( $line_items ) && ( empty( $shipping_amount ) ) ) || 1830 WC()->customer->is_vat_exempt() 2087 // Orders recalculated from cron or WP-CLI run without a customer session. 2088 ( WC()->customer instanceof WC_Customer && WC()->customer->is_vat_exempt() ) 1831 2089 ) { 1832 2090 $this->_log( 'Destination address or cart data is incomplete, or the customer is VAT exempt. Aborting.' ); … … 2045 2303 ); 2046 2304 2305 // Priorities written per tax class, for shadow_unscoped_rates_above_components(). 2306 $written = array(); 2307 2047 2308 // Add line item tax rates. 2048 2309 foreach ( $taxes['line_items'] as $line_item_key => $line_item ) { … … 2073 2334 continue; 2074 2335 } 2336 $rate_name = self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions ); 2337 2075 2338 $taxes['rate_ids'][ $line_item_key ][] = $this->create_or_update_tax_rate( 2076 2339 $location, … … 2079 2342 $taxes['freight_taxable'], 2080 2343 $priority, 2081 self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions )2344 $rate_name 2082 2345 ); 2083 2346 2347 $this->record_written_priority( $written, $tax_class, $priority, $rate_name ); 2084 2348 ++$priority; 2085 2349 } … … 2093 2357 continue; 2094 2358 } 2359 $rate_name = self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions ); 2360 2095 2361 $taxes['rate_ids']['shipping'][] = $this->create_or_update_tax_rate( 2096 2362 $location, … … 2099 2365 $taxes['freight_taxable'], 2100 2366 $priority, 2101 self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions )2367 $rate_name 2102 2368 ); 2103 2369 2370 $this->record_written_priority( $written, '', $priority, $rate_name ); 2104 2371 ++$priority; 2372 } 2373 2374 foreach ( $written as $tax_class => $class_written ) { 2375 if ( ! $class_written['vat'] ) { 2376 $this->shadow_unscoped_rates_above_components( $location, (string) $tax_class, $class_written['priorities'], $jurisdictions ); 2377 } 2105 2378 } 2106 2379 } … … 2178 2451 * it is handed. 2179 2452 */ 2180 $field = static function ( $value ) { 2181 return is_scalar( $value ) ? (string) $value : ''; 2182 }; 2183 2184 $address = Address::from_options( 2185 array( 2186 'to_country' => $field( $location['to_country'] ?? '' ), 2187 // Prevent filling "State code" column for countries with VAT tax. 2188 // VAT tax is country wide. 2189 'to_state' => 'VAT' === $tax_rate_name ? '' : $field( $location['to_state'] ?? '' ), 2190 'to_zip' => $field( $location['to_zip'] ?? '' ), 2191 'to_city' => $field( $location['to_city'] ?? '' ), 2192 ) 2193 ); 2453 $address = $this->get_rate_table_address( $location, 'VAT' === $tax_rate_name ); 2194 2454 2195 2455 /** … … 2238 2498 } 2239 2499 2500 /* 2501 * Never repurpose a row whose scope is broader than the jurisdiction about to 2502 * be written. Rows this method inserts always carry the address's postcode or 2503 * city, so a matched row with no location rows at all is a merchant's 2504 * catch-all (or a row written for a scopeless address). Rewriting it in place 2505 * would turn it into, say, a 0% City component that still matches the whole 2506 * state. 2507 * 2508 * Inserting beside it is stable: `WC_Tax::find_rates()` keeps one row per 2509 * priority and prefers the one with more postcode/city locations, so the next 2510 * lookup for this address lands on the scoped row and reuses it, while the 2511 * catch-all keeps covering every other address. 2512 * 2513 * VAT rows are country-wide by design and addresses without a postcode or city 2514 * write no scope, so both keep reusing the matched row. Otherwise they would 2515 * insert a new row on every calculation. 2516 */ 2517 if ( ! empty( $wc_rate ) && 'VAT' !== $tax_rate_name ) { 2518 $wanted_locations = $address->to_rate_table_locations(); 2519 $wants_scope = '' !== $wanted_locations['postcode'] || '' !== $wanted_locations['city']; 2520 2521 if ( $wants_scope && ! $this->tax_rate_has_locations( key( $wc_rate ) ) ) { 2522 $this->_log( ':: Matched Tax Rate Is Not Location Scoped, Adding A Scoped Rate Beside It ::' ); 2523 $wc_rate = array(); 2524 } 2525 } 2526 2240 2527 if ( ! empty( $wc_rate ) ) { 2241 2528 $this->_log( ':: Tax Rate Found ::' ); … … 2270 2557 2271 2558 return $rate_id; 2559 } 2560 2561 /** 2562 * Whether a tax rate row is restricted to any postcode or city. 2563 * 2564 * @param int $rate_id Tax rate ID. 2565 * 2566 * @return bool 2567 */ 2568 private function tax_rate_has_locations( $rate_id ) { 2569 global $wpdb; 2570 2571 // WooCommerce has no API for one rate's locations: get_rates_for_tax_class() loads every 2572 // row in the class. Not cached: the row's locations change within the request that asks. 2573 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 2574 return (bool) $wpdb->get_var( 2575 $wpdb->prepare( 2576 "SELECT 1 FROM {$wpdb->prefix}woocommerce_tax_rate_locations WHERE tax_rate_id = %d LIMIT 1", 2577 $rate_id 2578 ) 2579 ); 2580 } 2581 2582 /** 2583 * Note that a component was written at a priority in a tax class. 2584 * 2585 * @param array $written Written priorities per tax class, updated in place. 2586 * @param string $tax_class Tax class. 2587 * @param int $priority Priority. 2588 * @param string $rate_name Row name; a VAT row marks the class as VAT. 2589 */ 2590 private function record_written_priority( array &$written, $tax_class, $priority, $rate_name ) { 2591 if ( ! isset( $written[ $tax_class ] ) ) { 2592 $written[ $tax_class ] = array( 2593 'priorities' => array(), 2594 'vat' => false, 2595 ); 2596 } 2597 2598 $written[ $tax_class ]['priorities'][] = (int) $priority; 2599 $written[ $tax_class ]['vat'] = $written[ $tax_class ]['vat'] || 'VAT' === $rate_name; 2600 } 2601 2602 /** 2603 * The address a rate row is written and looked up for. 2604 * 2605 * Non-scalars collapse to an empty string rather than reaching `(string)` and 2606 * becoming the literal "Array": create_or_update_tax_rate() is public and takes 2607 * the location it is handed. VAT rows are country-wide, so they carry no state. 2608 * 2609 * @param array $location Location in the `to_*` shape. 2610 * @param bool $is_vat Whether the row is a VAT row. 2611 * 2612 * @return Address 2613 */ 2614 private function get_rate_table_address( $location, $is_vat ) { 2615 $field = static function ( $value ) { 2616 return is_scalar( $value ) ? (string) $value : ''; 2617 }; 2618 2619 return Address::from_options( 2620 array( 2621 'to_country' => $field( $location['to_country'] ?? '' ), 2622 // Prevent filling "State code" column for countries with VAT tax. 2623 // VAT tax is country wide. 2624 'to_state' => $is_vat ? '' : $field( $location['to_state'] ?? '' ), 2625 'to_zip' => $field( $location['to_zip'] ?? '' ), 2626 'to_city' => $field( $location['to_city'] ?? '' ), 2627 ) 2628 ); 2629 } 2630 2631 /** 2632 * Stop a state-wide rate from adding to the rates just written for an address. 2633 * 2634 * `WC_Tax::find_rates()` keeps one row per priority. The components TaxJar 2635 * returned sit at priorities 1 to N, so a merchant's state-wide row at a higher 2636 * priority still matches the address and is added on top of them. Checkout and 2637 * an admin Recalculate apply only the rate ids TaxJar returned, but everything 2638 * else that reads the rate table (REST and POS orders, shipping) would charge both. 2639 * 2640 * For each such priority this adds a 0% row limited to the address's postcode and 2641 * city. It outranks the state-wide row at that priority, so the table gives the 2642 * address what TaxJar returned, and every other address still gets the merchant's 2643 * row. The merchant's row is not changed. 2644 * 2645 * The row is not marked for shipping, and it still keeps the merchant's row off 2646 * shipping: `WC_Tax::find_shipping_rates()` picks one row per priority first and 2647 * only then drops the rows not marked for shipping. Marked for shipping, it would 2648 * put a $0 tax line on every order that ships to the address, since checkout, 2649 * order creation and Recalculate all tax shipping from the table. 2650 * 2651 * Only rows with no postcode or city are covered; a merchant's own local rows are 2652 * left as they are. Nothing is added for VAT rows, which are country-wide, or for 2653 * an address with no postcode or city, since the new row could not outrank anything. 2654 * The next lookup for the address finds the 0% row at that priority instead of the 2655 * state-wide one, so nothing more is added. 2656 * 2657 * The rows are inserted directly rather than through create_or_update_tax_rate(), 2658 * which matches by position and could otherwise update a merchant's local row. 2659 * 2660 * @param array $location Location in the `to_*` shape, as written. 2661 * @param string $tax_class Tax class the components were written in. 2662 * @param int[] $priorities Priorities the components were written at. 2663 * @param array $jurisdictions County and city names, for the row name. 2664 */ 2665 private function shadow_unscoped_rates_above_components( $location, $tax_class, array $priorities, array $jurisdictions ) { 2666 $address = $this->get_rate_table_address( $location, false ); 2667 $locations = $address->to_rate_table_locations(); 2668 2669 if ( '' === $locations['postcode'] && '' === $locations['city'] ) { 2670 return; 2671 } 2672 2673 $matched = WC_Tax::find_rates( $address->to_find_rates_args( $tax_class ) ); 2674 2675 foreach ( array_keys( is_array( $matched ) ? $matched : array() ) as $rate_id ) { 2676 $row = WC_Tax::_get_tax_rate( $rate_id ); 2677 2678 if ( ! $row || in_array( (int) $row['tax_rate_priority'], $priorities, true ) || $this->tax_rate_has_locations( $rate_id ) ) { 2679 continue; 2680 } 2681 2682 $this->_log( ':: Adding A 0% Rate Beside A State-Wide Rate At Priority ' . (int) $row['tax_rate_priority'] . ' ::' ); 2683 2684 $shadow_id = WC_Tax::_insert_tax_rate( 2685 array( 2686 'tax_rate_country' => $address->country(), 2687 'tax_rate_state' => $address->state_compact(), 2688 'tax_rate_name' => self::generate_nullified_rate_name( $address->country(), $jurisdictions ), 2689 'tax_rate_priority' => (int) $row['tax_rate_priority'], 2690 'tax_rate_compound' => false, 2691 'tax_rate_shipping' => 0, 2692 'tax_rate' => 0, 2693 'tax_rate_class' => $tax_class, 2694 ) 2695 ); 2696 2697 WC_Tax::_update_tax_rate_postcodes( $shadow_id, $locations['postcode'] ); 2698 WC_Tax::_update_tax_rate_cities( $shadow_id, $locations['city'] ); 2699 } 2272 2700 } 2273 2701 … … 2371 2799 2372 2800 // Clear the taxjar notices before calculating taxes or using cached response. 2373 $this->notifier->clear_notices( 'taxjar' ); 2801 if ( $this->notifier ) { 2802 $this->notifier->clear_notices( 'taxjar' ); 2803 } 2374 2804 2375 2805 if ( false === $response ) { … … 2511 2941 2512 2942 // If HPOS is enabled, and we're on the Orders list page, return false. 2943 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: under HPOS the Orders list and the single order screen share one screen id, and only the single order screen carries WooCommerce's action arg. This just tells the two apart before deciding whether to enqueue assets. 2513 2944 if ( 'woocommerce_page_wc-orders' === $wc_order_screen_id && ! isset( $_GET['action'] ) ) { 2514 2945 return false; … … 2526 2957 } 2527 2958 // Load Javascript for WooCommerce new order page 2528 wp_enqueue_script( 'wc-taxjar-order', $this->wc_connect_base_url . 'woocommerce-services-new-order-taxjar-' . WC_Connect_Loader::get_wcs_version() . '.js', array( 'jquery' ), null, true ); 2959 wp_enqueue_script( 'wc-taxjar-order', $this->wc_connect_base_url . 'woocommerce-services-new-order-taxjar-' . WC_Connect_Loader::get_wcs_version() . '.js', array( 'jquery' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Served from the Connect Server and the version is already in the file name, so no ?ver= is wanted. 2529 2960 } 2530 2961 … … 2563 2994 2564 2995 if ( 'not set' === $to_state || 'not set' === $to_country || null === $has_nexus ) { 2565 throw new Exception( sprintf( 'One or more values are not set : to_state=>%1$s, to_country=>%2$s, has_nexus=>%3$s', $to_state, $to_country, json_encode( $has_nexus ) ) ); 2566 } 2996 throw new Exception( sprintf( 'One or more values are not set : to_state=>%1$s, to_country=>%2$s, has_nexus=>%3$s', esc_html( $to_state ), esc_html( $to_country ), esc_html( wp_json_encode( $has_nexus ) ) ) ); 2997 } 2998 } 2999 3000 /** 3001 * Whether an out-of-cart recalculation of this order should have its taxes preserved. 3002 * 3003 * Both halves of the preserve/restore pair consult this, because they hang off hooks 3004 * with different firing conditions: preserve_order_taxes_on_recalculation() runs from 3005 * calculate_taxes(), while restore_order_taxes_after_recalculation() runs from 3006 * calculate_totals() whether or not taxes were recalculated. A snapshot can therefore 3007 * reach the restore half without this having been evaluated on the way in. 3008 * 3009 * The gates are exclusionary: preservation runs for ANY out-of-cart recalculation of 3010 * an existing order — the REST/address-change path this fix targets, but also WP-CLI, 3011 * cron, and Action Scheduler runs. That breadth is intentional: once an order is 3012 * placed its tax is a record of what was charged, so we preserve it on every 3013 * programmatic recalculation, not only the REST path that prompted this fix. 3014 * 3015 * @since 3.7.1 3016 * 3017 * @param WC_Order $order The order being recalculated. 3018 * @return bool 3019 */ 3020 private function should_preserve_order_taxes( $order ) { 3021 // The cart/checkout flow populates response_rate_ids and manages its own taxes. 3022 if ( ! empty( $this->response_rate_ids ) ) { 3023 return false; 3024 } 3025 3026 // Admin order edits recalculate over AJAX and are handled by calculate_backend_totals(). 3027 if ( wp_doing_ajax() ) { 3028 return false; 3029 } 3030 3031 // A new order that does not exist yet has no recorded taxes to preserve. 3032 if ( ! $order->get_id() ) { 3033 return false; 3034 } 3035 3036 // WC zeroes the taxes of an exempt order; restoring them would undo the exemption. 3037 /** 3038 * Filters whether an order is VAT exempt. A WooCommerce core filter, applied here 3039 * with the same arguments WC_Abstract_Order::calculate_taxes() passes. 3040 * 3041 * @since 3.7.1 Applied by this plugin. 3042 * 3043 * @param bool $is_vat_exempt Whether the order is VAT exempt. 3044 * @param WC_Order $order The order being recalculated. 3045 */ 3046 if ( apply_filters( 'woocommerce_order_is_vat_exempt', 'yes' === $order->get_meta( 'is_vat_exempt' ), $order ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core filter. 3047 return false; 3048 } 3049 3050 return true; 3051 } 3052 3053 /** 3054 * Whether a shipping line with no tax shows that the order's shipping is not taxed. 3055 * 3056 * A free line or one whose method is not taxable carries no tax on any order, so it 3057 * says nothing about whether the address taxes shipping. 3058 * 3059 * @param WC_Order_Item_Shipping $item A shipping line. 3060 * @return bool 3061 */ 3062 private function shipping_line_could_carry_tax( $item ) { 3063 return 'taxable' === $item->get_tax_status() && 0.0 !== (float) $item->get_total(); 2567 3064 } 2568 3065 … … 2574 3071 * the checkout address, so when a REST API or programmatic update changes the 2575 3072 * address the lookup returns nothing and update_taxes() wipes every tax line to 2576 * zero. Once an order is placed its tax is a record of what was charged, so we 2577 * preserve it rather than recompute it: snapshot the existing taxes here (before 2578 * WC recalculates) and restore them once the totals have been recalculated. 2579 * 2580 * The cart/checkout and admin-AJAX flows have their own handling and are left 2581 * untouched, and an order with no existing tax lines is left to calculate for the 2582 * first time normally. 3073 * zero. So the rate table is never consulted here: snapshot the existing taxes 3074 * (before WC recalculates) and, once the totals have been recalculated, either 3075 * restore them or re-apply the order's own rates. 3076 * 3077 * The rate is fixed when the order is placed; the amount follows the order. If no 3078 * item, fee or shipping amount changed, the recorded tax is restored exactly (so a 3079 * plain re-save moves nothing, and a hand-typed figure survives it). If some did, 3080 * the rates recorded on the order's tax lines are re-applied to the items that 3081 * changed, and the order gets a note with the old and new tax. See 3082 * restore_order_taxes_after_recalculation(). 3083 * 3084 * Two cases ask TaxJar for fresh rates instead, once the totals are recalculated: 3085 * the address the order is taxed on changed (a stored rate belongs to the old 3086 * address), or the order has no tax yet and its items changed, as a new order's 3087 * have (there is no rate to re-use). See lookup_order_taxes(). 3088 * 3089 * The cart/checkout, Store API and admin-AJAX flows have their own handling and are 3090 * left untouched, an order with no existing tax lines and nothing to look up is left 3091 * to WC, and a VAT-exempt order keeps the zero tax WC gives it. 2583 3092 * 2584 3093 * Restoration happens on woocommerce_order_after_calculate_totals, the only post- … … 2599 3108 */ 2600 3109 public function preserve_order_taxes_on_recalculation( $args, $order ) { 2601 // The gates below are exclusionary: preservation runs for ANY out-of-cart 2602 // recalculation of an existing, already-taxed order — the REST/address-change 2603 // path this fix targets, but also WP-CLI, cron, and Action Scheduler runs. 2604 // That breadth is intentional: once an order is placed its tax is a record of 2605 // what was charged, so we preserve it on every programmatic recalculation, not 2606 // only the REST path that prompted this fix. 2607 2608 // The cart/checkout flow populates response_rate_ids and manages its own taxes. 2609 if ( ! empty( $this->response_rate_ids ) ) { 3110 // A snapshot left by an earlier calculate_taxes() must not survive a rejected one. 3111 // Kept otherwise: when WC's own lookup has already wiped the tax lines, the next 3112 // snapshot is empty and the earlier one is the only record of what was charged. 3113 if ( ! $this->should_preserve_order_taxes( $order ) ) { 3114 unset( $this->pre_recalculation_tax_snapshots[ (int) $order->get_id() ] ); 2610 3115 return; 2611 3116 } 2612 3117 2613 // Admin order edits recalculate over AJAX and are handled by calculate_backend_totals(). 2614 if ( wp_doing_ajax() ) { 3118 $snapshot = $this->snapshot_order_taxes( $order ); 3119 3120 // WC's own lookup in an earlier calculate_taxes() may have wiped the tax lines. 3121 // The earlier snapshot then still holds what was charged, and its own decision 3122 // about asking TaxJar; an empty one must not replace it. 3123 if ( empty( $snapshot['tax_lines'] ) && ! empty( $this->pre_recalculation_tax_snapshots[ (int) $order->get_id() ]['tax_lines'] ) ) { 2615 3124 return; 2616 3125 } 2617 3126 2618 // A new order that does not exist yet has no recorded taxes to preserve. 2619 if ( ! $order->get_id() ) { 2620 return; 2621 } 2622 2623 $snapshot = $this->snapshot_order_taxes( $order ); 2624 2625 // Only preserve when the order already had tax lines; never turn a first-time 2626 // tax calculation into a zeroed one. 2627 if ( empty( $snapshot['tax_lines'] ) ) { 3127 // What changed has to be read now: WC saves the order (and its items) while it 3128 // recalculates, which clears the pending changes this relies on. 3129 $snapshot['base_changes'] = $this->find_order_tax_base_changes( $order ); 3130 $snapshot['address_before'] = $this->find_order_tax_address_change( $order ); 3131 $snapshot['lookup'] = $this->order_needs_tax_lookup( $snapshot ); 3132 3133 // A refund records its tax against the order's rate ids. TaxJar's rates for a new 3134 // address are stored under other ids, so after a lookup the refunded tax would 3135 // belong to rates the order no longer has. Keep the recorded rates instead; the 3136 // admin does not let a refunded order be edited at all. 3137 $snapshot['kept_for_refunds'] = $snapshot['lookup'] && ! empty( $snapshot['tax_lines'] ) && 0.0 !== (float) $order->get_total_tax_refunded(); 3138 if ( $snapshot['kept_for_refunds'] ) { 3139 $snapshot['lookup'] = false; 3140 } 3141 3142 // Without tax lines there is nothing to preserve; never turn a first-time tax 3143 // calculation into a zeroed one. Leave it to WC unless TaxJar is to be asked. 3144 if ( empty( $snapshot['tax_lines'] ) && ! $snapshot['lookup'] ) { 2628 3145 return; 2629 3146 } … … 2641 3158 * 2642 3159 * Looks up the snapshot captured in preserve_order_taxes_on_recalculation() for the 2643 * recalculated order and, if one is present, restores it and drops it from the3160 * recalculated order and, if one is present, applies it and drops it from the 2644 3161 * pending set. Keyed by order id so a batch that recalculates several orders 2645 3162 * restores each from its own snapshot. 2646 3163 * 3164 * - Nothing taxable changed: the recorded tax is restored exactly. 3165 * - Something changed: the order's recorded rates are re-applied to the changed 3166 * items (see reapply_order_tax_rates()). 3167 * - Something changed but the order does not record its rates (orders from before 3168 * WC 3.7): the recorded tax is restored and a note says it was not updated. 3169 * - The taxed address changed, or a new order has no tax yet: TaxJar is asked and 3170 * its rates replace the order's tax. If that fails, the cases above apply as if 3171 * no lookup was due, and a note says the tax could not be updated. 3172 * - The taxed address changed on an order with refunded tax: TaxJar is not asked, 3173 * so the refunds keep matching the order's rates. The cases above apply, and a 3174 * note says why the tax was not updated. 3175 * 2647 3176 * @internal Hooked to woocommerce_order_after_calculate_totals. 2648 3177 * … … 2660 3189 unset( $this->pre_recalculation_tax_snapshots[ $order_id ] ); 2661 3190 2662 $this->restore_order_taxes( $order, $snapshot ); 3191 // This hook fires from calculate_totals() whether or not taxes were recalculated, 3192 // so a snapshot can arrive here without the gates having run. Re-check them: an 3193 // order made VAT exempt since the snapshot was taken must not get its tax back. 3194 if ( ! $this->should_preserve_order_taxes( $order ) ) { 3195 return; 3196 } 3197 3198 $lookup = $snapshot['lookup'] ? $this->lookup_order_taxes( $order ) : null; 3199 $reapplied = false; 3200 3201 if ( is_array( $lookup ) ) { 3202 $this->apply_looked_up_order_taxes( $order, $lookup, $snapshot ); 3203 } elseif ( empty( $snapshot['tax_lines'] ) ) { 3204 // Nothing to keep: what WC calculated stands, as it did before the lookup existed. 3205 if ( self::ORDER_TAX_LOOKUP_BAD_ADDRESS === $lookup ) { 3206 $this->add_order_tax_note( $order, __( 'Tax could not be calculated for this order, because its address is incomplete or has a state or ZIP code that is not valid. Check the order\'s address and its tax.', 'woocommerce-services' ) ); 3207 } elseif ( self::ORDER_TAX_LOOKUP_BAD_STORE_ADDRESS === $lookup ) { 3208 $this->add_order_tax_note( $order, __( 'Tax could not be calculated for this order, because the store address has no country or has a ZIP code that is not valid. Check the store address in WooCommerce > Settings > General, then the tax on this order.', 'woocommerce-services' ) ); 3209 } elseif ( false === $lookup ) { 3210 $this->add_order_tax_note( $order, __( 'Tax could not be calculated for this order, because the tax service did not answer or did not accept the order\'s address. Check the order\'s address and its tax.', 'woocommerce-services' ) ); 3211 } 3212 } elseif ( empty( $snapshot['base_changes']['has_changes'] ) ) { 3213 $this->restore_order_taxes( $order, $snapshot ); 3214 } elseif ( ! $this->snapshot_records_tax_rates( $snapshot ) ) { 3215 $this->restore_order_taxes( $order, $snapshot ); 3216 $order->add_order_note( __( 'Tax was not updated after this order changed, because the order does not record the tax rates it was placed with. Check the tax on this order.', 'woocommerce-services' ) ); 3217 } else { 3218 $this->reapply_order_tax_rates( $order, $snapshot ); 3219 $reapplied = true; 3220 } 3221 3222 if ( ! is_array( $lookup ) && null !== $lookup && ! empty( $snapshot['tax_lines'] ) ) { 3223 $this->add_order_tax_note( $order, $this->get_failed_order_tax_update_note( $lookup, $reapplied ) ); 3224 } 3225 3226 if ( ! empty( $snapshot['kept_for_refunds'] ) ) { 3227 $this->add_order_tax_note( 3228 $order, 3229 $reapplied 3230 ? __( 'Tax was not updated for the new address, because this order has refunded tax. The tax rates recorded when the order was placed were used instead. Check the order\'s address and its tax.', 'woocommerce-services' ) 3231 : __( 'Tax was not updated for the new address, because this order has refunded tax. The tax this order already had was kept. Check the order\'s address and its tax.', 'woocommerce-services' ) 3232 ); 3233 } 3234 3235 // The old amounts and address have been used; a later recalculation of the same 3236 // order in this request must compare against what is saved now. 3237 foreach ( $order->get_items( array( 'line_item', 'fee', 'shipping' ) ) as $item_id => $item ) { 3238 unset( $this->order_item_base_before_save[ $item_id ], $this->order_items_created_in_request['ids'][ $item_id ], $this->order_item_tax_locations[ spl_object_id( $item ) ] ); 3239 } 3240 unset( $this->order_address_before_save[ $order_id ], $this->orders_created_in_request['ids'][ $order_id ] ); 3241 } 3242 3243 /** 3244 * Remember an order item's taxable amount before a save changes it. 3245 * 3246 * The REST API saves an edited item before it recalculates the order, so by the 3247 * time preserve_order_taxes_on_recalculation() runs, the item no longer shows what 3248 * it was. Only the first save in a request is kept: that is the amount the order's 3249 * recorded tax was based on. 3250 * 3251 * @internal Hooked to woocommerce_before_order_item_object_save. 3252 * 3253 * @param WC_Order_Item $item The item about to be saved. 3254 * 3255 * @since 3.7.1 3256 */ 3257 public function remember_order_item_base_before_save( $item ) { 3258 if ( ! $item instanceof WC_Order_Item || ! in_array( $item->get_type(), array( 'line_item', 'fee', 'shipping' ), true ) ) { 3259 return; 3260 } 3261 3262 $item_id = (int) $item->get_id(); 3263 3264 if ( ! $item_id ) { 3265 $this->order_items_created_in_request['objects'][ spl_object_id( $item ) ] = true; 3266 return; 3267 } 3268 3269 if ( isset( $this->order_item_base_before_save[ $item_id ] ) ) { 3270 return; 3271 } 3272 3273 $changes = $item->get_changes(); 3274 3275 if ( ! array_key_exists( 'total', $changes ) && ! array_key_exists( 'subtotal', $changes ) ) { 3276 return; 3277 } 3278 3279 // get_data() still holds the saved values until the save applies the changes. 3280 $this->order_item_base_before_save[ $item_id ] = self::get_order_item_tax_base( $item->get_data() ); 3281 } 3282 3283 /** 3284 * Remember the location WooCommerce taxed an order item for. 3285 * 3286 * @internal Hooked to woocommerce_order_item_after_calculate_taxes and its fee and 3287 * shipping counterparts, at the earliest priority. 3288 * 3289 * @param WC_Order_Item $item The item WooCommerce just taxed. 3290 * @param mixed $calculate_tax_for The location it was taxed for. 3291 * 3292 * @since 3.7.1 3293 */ 3294 public function remember_order_item_tax_location( $item, $calculate_tax_for = null ) { 3295 if ( ! $item instanceof WC_Order_Item || ! is_array( $calculate_tax_for ) || ! isset( $this->pre_recalculation_tax_snapshots[ (int) $item->get_order_id() ] ) ) { 3296 return; 3297 } 3298 3299 $this->order_item_tax_locations[ spl_object_id( $item ) ] = $calculate_tax_for; 3300 } 3301 3302 /** 3303 * Run an item's after-calculate-taxes hook again, on the tax set for it here. 3304 * 3305 * WooCommerce fires the hook once it has taxed an item, and other plugins adjust the 3306 * tax there (an exemption plugin zeroes it). The tax set here replaces WooCommerce's, 3307 * so the hook runs again with the same arguments, and those plugins adjust this tax 3308 * as they adjusted WooCommerce's. Nothing runs for an item WooCommerce did not tax in 3309 * this recalculation. 3310 * 3311 * @param WC_Order_Item $item An item whose tax was just set. 3312 */ 3313 private function run_order_item_tax_hook( $item ) { 3314 $key = spl_object_id( $item ); 3315 $hooks = self::ORDER_ITEM_TAX_HOOKS; 3316 if ( ! isset( $this->order_item_tax_locations[ $key ], $hooks[ $item->get_type() ] ) ) { 3317 return; 3318 } 3319 3320 /** 3321 * Fires after an order item's taxes are calculated. WooCommerce core's 3322 * woocommerce_order_item_after_calculate_taxes, or its fee or shipping 3323 * counterpart, run again on the tax this plugin set. 3324 * 3325 * @since 3.7.1 Run again by this plugin. 3326 * 3327 * @param WC_Order_Item $item The order item. 3328 * @param array $calculate_tax_for The location the item was taxed for. 3329 */ 3330 do_action( $hooks[ $item->get_type() ], $item, $this->order_item_tax_locations[ $key ] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- WooCommerce core hooks, see ORDER_ITEM_TAX_HOOKS. 3331 } 3332 3333 /** 3334 * Record the id an order item was given when it was first saved. 3335 * 3336 * @internal Hooked to woocommerce_after_order_item_object_save. 3337 * 3338 * @param WC_Order_Item $item The saved item. 3339 * 3340 * @since 3.7.1 3341 */ 3342 public function remember_order_item_created( $item ) { 3343 if ( ! $item instanceof WC_Order_Item ) { 3344 return; 3345 } 3346 3347 $object_id = spl_object_id( $item ); 3348 3349 if ( isset( $this->order_items_created_in_request['objects'][ $object_id ] ) ) { 3350 unset( $this->order_items_created_in_request['objects'][ $object_id ] ); 3351 $this->order_items_created_in_request['ids'][ (int) $item->get_id() ] = true; 3352 } 2663 3353 } 2664 3354 … … 2773 3463 $order->save(); 2774 3464 } 3465 3466 /** 3467 * Work out which of an order's taxable amounts changed since it was last saved. 3468 * 3469 * Covers the three ways an edit reaches a recalculation: an existing item whose 3470 * total changed (saved already, or still pending), an item added (no id yet, or 3471 * first saved in this request), and an item removed (still saved, but no longer on 3472 * the order). 3473 * 3474 * @param WC_Order $order The order about to be recalculated. 3475 * @return array { 3476 * @type int[] $known_ids Items saved before this request, still on the order. 3477 * @type int[] $changed_ids Saved items whose taxable amount changed. 3478 * @type array $new_item_taxes Taxes of new items that arrived already taxed, keyed by spl_object_id(). 3479 * @type array $removed_rate_ids Rate ids of removed items: 'classes' keyed by tax class, and 'shipping'; 3480 * 'untaxed_shipping' is true when a removed shipping line carried no tax. 3481 * @type bool $has_changes Whether anything taxable changed, including additions and removals. 3482 * } 3483 */ 3484 private function find_order_tax_base_changes( $order ) { 3485 $types = array( 'line_item', 'fee', 'shipping' ); 3486 $known_ids = array(); 3487 $changed_ids = array(); 3488 $new_item_taxes = array(); 3489 $added = false; 3490 3491 foreach ( $order->get_items( $types ) as $item ) { 3492 $item_id = (int) $item->get_id(); 3493 $is_new = ! $item_id || isset( $this->order_items_created_in_request['ids'][ $item_id ] ); 3494 3495 if ( $is_new ) { 3496 $added = true; 3497 3498 // Its tax was set by whoever added it (the Store API copies the cart's), 3499 // so it was taxed at its own sale. Keyed by object: it may have no id yet. 3500 $taxes = $item->get_taxes(); 3501 if ( ! empty( $taxes['total'] ) ) { 3502 $new_item_taxes[ spl_object_id( $item ) ] = $taxes; 3503 } 3504 continue; 3505 } 3506 3507 $known_ids[] = $item_id; 3508 3509 if ( $this->order_item_base_moved( $item ) ) { 3510 $changed_ids[] = $item_id; 3511 } 3512 } 3513 3514 $saved_items = array(); 3515 foreach ( $types as $type ) { 3516 $saved_items += (array) $order->get_data_store()->read_items( $order, $type ); 3517 } 3518 $removed = array_diff( array_map( 'intval', array_keys( $saved_items ) ), $known_ids, array_keys( $this->order_items_created_in_request['ids'] ) ); 3519 3520 // The rates removed items were taxed at, for a new item that replaces the last 3521 // one of its tax class. They have to be read now: the recalculation deletes them. 3522 $removed_rate_ids = array( 3523 'classes' => array(), 3524 'shipping' => array(), 3525 'untaxed_shipping' => false, 3526 ); 3527 foreach ( $removed as $item_id ) { 3528 // read_items() gives false for an item whose class cannot be loaded. 3529 if ( ! $saved_items[ $item_id ] instanceof WC_Order_Item ) { 3530 continue; 3531 } 3532 3533 $taxes = $saved_items[ $item_id ]->get_taxes(); 3534 $rate_ids = empty( $taxes['total'] ) ? array() : array_keys( $taxes['total'] ); 3535 3536 if ( 'shipping' === $saved_items[ $item_id ]->get_type() ) { 3537 $removed_rate_ids['shipping'] = array_unique( array_merge( $removed_rate_ids['shipping'], $rate_ids ) ); 3538 $removed_rate_ids['untaxed_shipping'] = $removed_rate_ids['untaxed_shipping'] || ( ! $rate_ids && $this->shipping_line_could_carry_tax( $saved_items[ $item_id ] ) ); 3539 } elseif ( $rate_ids ) { 3540 $tax_class = $saved_items[ $item_id ]->get_tax_class(); 3541 $removed_rate_ids['classes'][ $tax_class ] = array_unique( array_merge( $removed_rate_ids['classes'][ $tax_class ] ?? array(), $rate_ids ) ); 3542 } 3543 } 3544 3545 return array( 3546 'known_ids' => $known_ids, 3547 'changed_ids' => $changed_ids, 3548 'new_item_taxes' => $new_item_taxes, 3549 'removed_rate_ids' => $removed_rate_ids, 3550 'has_changes' => $added || ! empty( $removed ) || ! empty( $changed_ids ), 3551 ); 3552 } 3553 3554 /** 3555 * Whether a saved order item's taxable amount differs from what it was taxed on. 3556 * 3557 * Compared as numbers: a caller that re-sends "10.00" for a stored "10" has not 3558 * changed anything. 3559 * 3560 * @param WC_Order_Item $item A saved line item, fee or shipping item. 3561 * @return bool 3562 */ 3563 private function order_item_base_moved( $item ) { 3564 $item_id = (int) $item->get_id(); 3565 3566 if ( isset( $this->order_item_base_before_save[ $item_id ] ) ) { 3567 $before = $this->order_item_base_before_save[ $item_id ]; 3568 } else { 3569 $changes = $item->get_changes(); 3570 3571 if ( ! array_key_exists( 'total', $changes ) && ! array_key_exists( 'subtotal', $changes ) ) { 3572 return false; 3573 } 3574 3575 $before = self::get_order_item_tax_base( $item->get_data() ); 3576 } 3577 3578 $after = self::get_order_item_tax_base( 3579 array( 3580 'total' => $item->get_total(), 3581 'subtotal' => is_callable( array( $item, 'get_subtotal' ) ) ? $item->get_subtotal() : null, 3582 ) 3583 ); 3584 3585 foreach ( array( 'total', 'subtotal' ) as $key ) { 3586 if ( abs( (float) $before[ $key ] - (float) $after[ $key ] ) > 0.000001 ) { 3587 return true; 3588 } 3589 } 3590 3591 return false; 3592 } 3593 3594 /** 3595 * The amounts an order item's tax is calculated on. 3596 * 3597 * @param array $data Item data (WC_Order_Item::get_data() shape). 3598 * @return array{total: float|null, subtotal: float|null} 3599 */ 3600 private static function get_order_item_tax_base( $data ) { 3601 return array( 3602 'total' => isset( $data['total'] ) ? (float) $data['total'] : null, 3603 'subtotal' => isset( $data['subtotal'] ) ? (float) $data['subtotal'] : null, 3604 ); 3605 } 3606 3607 /** 3608 * Whether every tax line in a snapshot records the rate it was charged at. 3609 * 3610 * Orders from before WC 3.7 have no rate_percent, and WC reads the missing value 3611 * back as 0. A 0% line that carries tax therefore means "rate unknown", and 3612 * re-applying 0% to it would wipe real tax. 3613 * 3614 * @param array $snapshot Snapshot returned by snapshot_order_taxes(). 3615 * @return bool 3616 */ 3617 private function snapshot_records_tax_rates( $snapshot ) { 3618 foreach ( $snapshot['tax_lines'] as $tax_line ) { 3619 if ( 0.0 === (float) $tax_line['rate_percent'] && ( 0.0 !== (float) $tax_line['tax_total'] || 0.0 !== (float) $tax_line['shipping_tax_total'] ) ) { 3620 return false; 3621 } 3622 } 3623 3624 return true; 3625 } 3626 3627 /** 3628 * Re-apply an order's recorded rates to the items whose amounts changed. 3629 * 3630 * Items that did not change keep the tax they had. A changed item is taxed at the 3631 * rates it already carries; a new item or fee at the rates of an existing item in 3632 * the same tax class; a new shipping line at the rates of the existing shipping. 3633 * If the edit removed the last item of that class (or all the shipping), the rates 3634 * the removed items were taxed at are used. With no such rate on the order, a new 3635 * item is left untaxed and the note says so, unless it is shipping on an order 3636 * whose shipping was not taxed either. 3637 * A new item that arrives with its tax already set keeps it. 3638 * Percentages, labels and codes come from the order's tax lines, never from the 3639 * rate table, which may have changed since the order was placed. 3640 * 3641 * @param WC_Order $order The recalculated order. 3642 * @param array $snapshot Snapshot taken before the recalculation. 3643 */ 3644 private function reapply_order_tax_rates( $order, $snapshot ) { 3645 $tax_lines_by_rate = array(); 3646 $rates = array(); 3647 foreach ( $snapshot['tax_lines'] as $tax_line ) { 3648 $rate_id = (int) $tax_line['rate_id']; 3649 $tax_lines_by_rate[ $rate_id ] = $tax_line; 3650 $rates[ $rate_id ] = array( 3651 'rate' => (float) $tax_line['rate_percent'], 3652 'label' => $tax_line['label'], 3653 'shipping' => 'yes', 3654 'compound' => $tax_line['compound'] ? 'yes' : 'no', 3655 ); 3656 } 3657 3658 $known_ids = $snapshot['base_changes']['known_ids']; 3659 $changed_ids = $snapshot['base_changes']['changed_ids']; 3660 3661 // Rates already on the order, per tax class and for shipping, for new items. 3662 $rate_ids_by_class = array(); 3663 $shipping_rate_ids = array(); 3664 $untaxed_shipping = $snapshot['base_changes']['removed_rate_ids']['untaxed_shipping']; 3665 foreach ( $order->get_items( array( 'line_item', 'fee', 'shipping' ) ) as $item_id => $item ) { 3666 $key = 'shipping' === $item->get_type() ? 'shipping_' . $item_id : $item_id; 3667 if ( ! in_array( (int) $item_id, $known_ids, true ) ) { 3668 continue; 3669 } 3670 3671 if ( empty( $snapshot['item_taxes'][ $key ]['total'] ) ) { 3672 // Shipping the order did not tax: a new shipping line at no tax is expected. 3673 $untaxed_shipping = $untaxed_shipping || ( 'shipping' === $item->get_type() && $this->shipping_line_could_carry_tax( $item ) ); 3674 continue; 3675 } 3676 3677 $item_rate_ids = array_keys( $snapshot['item_taxes'][ $key ]['total'] ); 3678 if ( 'shipping' === $item->get_type() ) { 3679 $shipping_rate_ids = array_unique( array_merge( $shipping_rate_ids, $item_rate_ids ) ); 3680 } else { 3681 $tax_class = $item->get_tax_class(); 3682 $rate_ids_by_class[ $tax_class ] = array_unique( array_merge( $rate_ids_by_class[ $tax_class ] ?? array(), $item_rate_ids ) ); 3683 } 3684 } 3685 3686 // When the edit removed the last item of a class (or all the shipping), a new one 3687 // takes the rates the removed ones were taxed at. 3688 $rate_ids_by_class += $snapshot['base_changes']['removed_rate_ids']['classes']; 3689 if ( empty( $shipping_rate_ids ) ) { 3690 $shipping_rate_ids = $snapshot['base_changes']['removed_rate_ids']['shipping']; 3691 } 3692 3693 $untaxed = array(); 3694 foreach ( $order->get_items( array( 'line_item', 'fee', 'shipping' ) ) as $item_id => $item ) { 3695 $is_shipping = 'shipping' === $item->get_type(); 3696 $key = $is_shipping ? 'shipping_' . $item_id : $item_id; 3697 $is_known = in_array( (int) $item_id, $known_ids, true ); 3698 3699 if ( $is_known && ! in_array( (int) $item_id, $changed_ids, true ) ) { 3700 if ( isset( $snapshot['item_taxes'][ $key ] ) ) { 3701 $item->set_taxes( $snapshot['item_taxes'][ $key ] ); 3702 } 3703 continue; 3704 } 3705 3706 if ( ! $is_known && isset( $snapshot['base_changes']['new_item_taxes'][ spl_object_id( $item ) ] ) ) { 3707 $item->set_taxes( $snapshot['base_changes']['new_item_taxes'][ spl_object_id( $item ) ] ); 3708 continue; 3709 } 3710 3711 if ( 'taxable' !== $item->get_tax_status() ) { 3712 $item_rate_ids = array(); 3713 } elseif ( $is_known ) { 3714 $item_rate_ids = empty( $snapshot['item_taxes'][ $key ]['total'] ) ? array() : array_keys( $snapshot['item_taxes'][ $key ]['total'] ); 3715 } elseif ( $is_shipping ) { 3716 $item_rate_ids = $shipping_rate_ids; 3717 } else { 3718 $item_rate_ids = $rate_ids_by_class[ $item->get_tax_class() ] ?? array(); 3719 } 3720 3721 $item_rates = array_intersect_key( $rates, array_flip( array_map( 'intval', $item_rate_ids ) ) ); 3722 3723 if ( empty( $item_rates ) ) { 3724 if ( ! $is_known && 'taxable' === $item->get_tax_status() && ! ( $is_shipping && $untaxed_shipping ) ) { 3725 $untaxed[] = $item->get_name(); 3726 } 3727 $item->set_taxes( false ); 3728 $this->run_order_item_tax_hook( $item ); 3729 continue; 3730 } 3731 3732 $taxes = array( 'total' => WC_Tax::calc_tax( $item->get_total(), $item_rates, false ) ); 3733 if ( is_callable( array( $item, 'get_subtotal' ) ) ) { 3734 $taxes['subtotal'] = WC_Tax::calc_tax( $item->get_subtotal(), $item_rates, false ); 3735 } 3736 $item->set_taxes( $taxes ); 3737 $this->run_order_item_tax_hook( $item ); 3738 } 3739 3740 // Put back what the order recorded about each rate: update_taxes() reads it 3741 // from the rate table. 3742 $recorded = array(); 3743 foreach ( $tax_lines_by_rate as $rate_id => $tax_line ) { 3744 $recorded[ $rate_id ] = array_intersect_key( $tax_line, array_flip( array( 'rate_code', 'label', 'rate_percent', 'compound' ) ) ); 3745 } 3746 $this->rebuild_order_tax_totals( $order, $recorded ); 3747 3748 $old_tax = (float) $snapshot['cart_tax'] + (float) $snapshot['shipping_tax']; 3749 $new_tax = (float) $order->get_cart_tax() + (float) $order->get_shipping_tax(); 3750 $notes = array(); 3751 3752 if ( abs( $old_tax - $new_tax ) > 0.000001 ) { 3753 $notes[] = sprintf( 3754 /* translators: 1: tax before the order changed, 2: tax after. */ 3755 __( 'Tax updated because the order changed: %1$s → %2$s. The tax rates recorded when the order was placed were used.', 'woocommerce-services' ), 3756 wc_price( $old_tax, array( 'currency' => $order->get_currency() ) ), 3757 wc_price( $new_tax, array( 'currency' => $order->get_currency() ) ) 3758 ); 3759 } 3760 3761 if ( $untaxed ) { 3762 $notes[] = sprintf( 3763 /* translators: %s: comma-separated names of order items. */ 3764 __( 'No tax was added for %s, because the order has no recorded tax rate for it. Check the tax on this order.', 'woocommerce-services' ), 3765 implode( ', ', array_map( 'wp_strip_all_tags', $untaxed ) ) 3766 ); 3767 } 3768 3769 if ( $notes ) { 3770 $this->add_order_tax_note( $order, implode( ' ', $notes ) ); 3771 } 3772 } 3773 3774 /** 3775 * Rebuild an order's tax lines and totals from the taxes now set on its items. 3776 * 3777 * WC has already recomputed the non-tax amounts by the time this runs; they are 3778 * kept and only the tax is swapped. 3779 * 3780 * @param WC_Order $order The recalculated order. 3781 * @param array $recorded Tax line fields to set per rate id after update_taxes() 3782 * (any of rate_code, label, rate_percent, compound). 3783 */ 3784 private function rebuild_order_tax_totals( $order, array $recorded ) { 3785 $non_tax_total = (float) $order->get_total() - (float) $order->get_cart_tax() - (float) $order->get_shipping_tax(); 3786 3787 $order->update_taxes(); 3788 foreach ( $order->get_taxes() as $tax_item ) { 3789 $rate_id = (int) $tax_item->get_rate_id(); 3790 foreach ( $recorded[ $rate_id ] ?? array() as $field => $value ) { 3791 $tax_item->{'set_' . $field}( $value ); 3792 } 3793 } 3794 3795 // Same sum WC_Abstract_Order::calculate_totals() uses for the discount tax. 3796 $subtotal_tax = 0.0; 3797 $total_tax = 0.0; 3798 foreach ( $order->get_items() as $item ) { 3799 $taxes = $item->get_taxes(); 3800 $total_tax += array_sum( array_map( 'floatval', $taxes['total'] ) ); 3801 $subtotal_tax += array_sum( array_map( 'floatval', $taxes['subtotal'] ) ); 3802 } 3803 $order->set_discount_tax( wc_round_tax_total( $subtotal_tax - $total_tax ) ); 3804 $order->set_total( round( $non_tax_total + (float) $order->get_cart_tax() + (float) $order->get_shipping_tax(), wc_get_price_decimals() ) ); 3805 $order->save(); 3806 } 3807 3808 /** 3809 * Add a note about the order's tax. 3810 * 3811 * A checkout draft is still being built from the cart; a note there would outlive 3812 * the checkout and describe changes the merchant never made. 3813 * 3814 * @param WC_Order $order The order. 3815 * @param string $note Note text. 3816 */ 3817 private function add_order_tax_note( $order, $note ) { 3818 if ( 'checkout-draft' !== $order->get_status() ) { 3819 $order->add_order_note( $note ); 3820 } 3821 } 3822 3823 /** 3824 * The note for an address change whose lookup failed on an order that had tax. 3825 * 3826 * Says why the lookup failed and what was done instead: the order's tax was kept, 3827 * or, when other changes were made in the same edit, the recorded rates were 3828 * re-applied to them. 3829 * 3830 * @param string|false $lookup Failed result of lookup_order_taxes(). 3831 * @param bool $reapplied Whether the recorded rates were re-applied. 3832 * @return string 3833 */ 3834 private function get_failed_order_tax_update_note( $lookup, $reapplied ) { 3835 if ( self::ORDER_TAX_LOOKUP_BAD_ADDRESS === $lookup ) { 3836 return $reapplied 3837 ? __( 'Tax could not be updated for the new address, because it is incomplete or has a state or ZIP code that is not valid. The tax rates recorded when the order was placed were used instead. Check the order\'s address and its tax.', 'woocommerce-services' ) 3838 : __( 'Tax could not be updated for the new address, because it is incomplete or has a state or ZIP code that is not valid. The tax this order already had was kept. Check the order\'s address and its tax.', 'woocommerce-services' ); 3839 } 3840 3841 if ( self::ORDER_TAX_LOOKUP_BAD_STORE_ADDRESS === $lookup ) { 3842 return $reapplied 3843 ? __( 'Tax could not be updated for the new address, because the store address has no country or has a ZIP code that is not valid. The tax rates recorded when the order was placed were used instead. Check the store address in WooCommerce > Settings > General, then the tax on this order.', 'woocommerce-services' ) 3844 : __( 'Tax could not be updated for the new address, because the store address has no country or has a ZIP code that is not valid. The tax this order already had was kept. Check the store address in WooCommerce > Settings > General, then the tax on this order.', 'woocommerce-services' ); 3845 } 3846 3847 return $reapplied 3848 ? __( 'Tax could not be updated for the new address, because the tax service did not answer or did not accept the address. The tax rates recorded when the order was placed were used instead. Check the order\'s address and its tax.', 'woocommerce-services' ) 3849 : __( 'Tax could not be updated for the new address, because the tax service did not answer or did not accept the address. The tax this order already had was kept. Check the order\'s address and its tax.', 'woocommerce-services' ); 3850 } 3851 3852 /** 3853 * Remember an order's address before a save changes it, and which orders are new. 3854 * 3855 * Only the first save in a request is kept: that is the address the order's 3856 * recorded tax was calculated for. 3857 * 3858 * @internal Hooked to woocommerce_before_order_object_save. 3859 * 3860 * @param WC_Order $order The order about to be saved. 3861 * 3862 * @since 3.7.1 3863 */ 3864 public function remember_order_before_save( $order ) { 3865 if ( ! $order instanceof WC_Order ) { 3866 return; 3867 } 3868 3869 $order_id = (int) $order->get_id(); 3870 3871 if ( ! $order_id ) { 3872 $this->orders_created_in_request['objects'][ spl_object_id( $order ) ] = true; 3873 return; 3874 } 3875 3876 // An order created in this request had no address its tax was based on. 3877 if ( isset( $this->order_address_before_save[ $order_id ] ) || isset( $this->orders_created_in_request['ids'][ $order_id ] ) ) { 3878 return; 3879 } 3880 3881 $changes = $order->get_changes(); 3882 3883 if ( empty( $changes['billing'] ) && empty( $changes['shipping'] ) ) { 3884 return; 3885 } 3886 3887 // get_data() still holds the saved values until the save applies the changes. 3888 $data = $order->get_data(); 3889 3890 $this->order_address_before_save[ $order_id ] = array( 3891 'billing' => $data['billing'], 3892 'shipping' => $data['shipping'], 3893 ); 3894 } 3895 3896 /** 3897 * Record the id a new order was given when it was first saved. 3898 * 3899 * @internal Hooked to woocommerce_after_order_object_save. 3900 * 3901 * @param WC_Order $order The saved order. 3902 * 3903 * @since 3.7.1 3904 */ 3905 public function remember_order_created( $order ) { 3906 if ( ! $order instanceof WC_Order ) { 3907 return; 3908 } 3909 3910 $object_id = spl_object_id( $order ); 3911 3912 if ( isset( $this->orders_created_in_request['objects'][ $object_id ] ) ) { 3913 unset( $this->orders_created_in_request['objects'][ $object_id ] ); 3914 $this->orders_created_in_request['ids'][ (int) $order->get_id() ] = true; 3915 } 3916 } 3917 3918 /** 3919 * The address an order was taxed on before this request changed it. 3920 * 3921 * Compares the address WC would tax the order on (see get_order_tax_address()) 3922 * before and after, street included: two addresses in the same ZIP can have 3923 * different rates. 3924 * 3925 * @param WC_Order $order The order about to be recalculated. 3926 * @return Address|null The old address, or null if it did not change. 3927 */ 3928 private function find_order_tax_address_change( $order ) { 3929 $order_id = (int) $order->get_id(); 3930 3931 if ( isset( $this->order_address_before_save[ $order_id ] ) ) { 3932 $before = $this->order_address_before_save[ $order_id ]; 3933 unset( $this->order_address_before_save[ $order_id ] ); 3934 } else { 3935 $data = $order->get_data(); 3936 $before = array( 3937 'billing' => $data['billing'], 3938 'shipping' => $data['shipping'], 3939 ); 3940 } 3941 3942 $location_type = $this->get_order_tax_location_type( $order ); 3943 $old_address = $this->get_order_tax_address( $before['billing'], $before['shipping'], $location_type ); 3944 $new_address = $this->get_order_tax_address( $order->get_address( 'billing' ), $order->get_address( 'shipping' ), $location_type ); 3945 3946 return $old_address->to_taxable_tuple() === $new_address->to_taxable_tuple() ? null : $old_address; 3947 } 3948 3949 /** 3950 * Whether TaxJar should be asked for this order's rates once it is recalculated. 3951 * 3952 * Asked when the address the order is taxed on changed, or when the order has no 3953 * tax yet and its items changed. A new order counts: its items are first saved in 3954 * the same request. Everything else re-uses the rates recorded on the order, with 3955 * no request. 3956 * 3957 * @param array $snapshot Snapshot with base_changes and address_before. 3958 * @return bool 3959 */ 3960 private function order_needs_tax_lookup( array $snapshot ) { 3961 // The Store API is the cart's own flow. 3962 if ( WC_Connect_Functions::is_store_api_call() ) { 3963 return false; 3964 } 3965 3966 if ( null !== $snapshot['address_before'] ) { 3967 return true; 3968 } 3969 3970 if ( ! empty( $snapshot['tax_lines'] ) ) { 3971 return false; 3972 } 3973 3974 return ! empty( $snapshot['base_changes']['has_changes'] ); 3975 } 3976 3977 /** 3978 * Which address an order is taxed on: 'base', 'billing' or 'shipping'. 3979 * 3980 * Mirrors WC_Abstract_Order::get_tax_location(): the Tax "Calculate tax based on" 3981 * setting, and the store address when the order uses local pickup. 3982 * 3983 * @param WC_Order $order The order. 3984 * @return string 3985 */ 3986 private function get_order_tax_location_type( $order ) { 3987 /** 3988 * Filters whether to apply base tax for local pickup. A WooCommerce core filter, 3989 * applied here as WC_Abstract_Order::get_tax_location() applies it. 3990 * 3991 * @since 3.7.1 Applied by this plugin. 3992 * 3993 * @param bool $apply_base_tax Whether to apply base tax for local pickup. 3994 */ 3995 if ( true === apply_filters( 'woocommerce_apply_base_tax_for_local_pickup', true ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core filter. 3996 /** 3997 * Filters the local pickup shipping method ids. A WooCommerce core filter. 3998 * 3999 * @since 3.7.1 Applied by this plugin. 4000 * 4001 * @param string[] $local_pickup_methods Local pickup shipping method ids. 4002 */ 4003 $local_pickup_methods = (array) apply_filters( 'woocommerce_local_pickup_methods', array( 'legacy_local_pickup', 'local_pickup' ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core filter. 4004 4005 foreach ( $order->get_shipping_methods() as $shipping ) { 4006 if ( in_array( $shipping->get_method_id(), $local_pickup_methods, true ) ) { 4007 return 'base'; 4008 } 4009 } 4010 } 4011 4012 return (string) get_option( 'woocommerce_tax_based_on', 'shipping' ); 4013 } 4014 4015 /** 4016 * The address an order is taxed on, built from its billing and shipping fields. 4017 * 4018 * Mirrors WC_Abstract_Order::get_tax_location(): shipping falls back to billing when 4019 * the order has no shipping country, and the store address is used when the 4020 * location type is 'base' or the chosen address has no country. 4021 * 4022 * @param array $billing Billing fields (WC_Order::get_address() shape). 4023 * @param array $shipping Shipping fields (WC_Order::get_address() shape). 4024 * @param string $location_type 'base', 'billing' or 'shipping'. 4025 * @return Address 4026 */ 4027 private function get_order_tax_address( array $billing, array $shipping, $location_type ) { 4028 if ( 'shipping' === $location_type && empty( $shipping['country'] ) ) { 4029 $location_type = 'billing'; 4030 } 4031 4032 $fields = 'billing' === $location_type ? $billing : $shipping; 4033 4034 if ( 'base' === $location_type || empty( $fields['country'] ) ) { 4035 return Address::from_store_settings( $this->get_store_settings() ); 4036 } 4037 4038 return Address::from_options( 4039 array( 4040 'to_country' => $fields['country'] ?? '', 4041 'to_state' => $fields['state'] ?? '', 4042 'to_zip' => $fields['postcode'] ?? '', 4043 'to_city' => $fields['city'] ?? '', 4044 'to_street' => $fields['address_1'] ?? '', 4045 ) 4046 ); 4047 } 4048 4049 /** 4050 * Ask TaxJar for the rates of an order at its current address. 4051 * 4052 * Built like the admin Recalculate request (same line items), with the address taken 4053 * from the order instead of the form. Items whose tax location is filtered to a 4054 * different address are asked for separately, as at checkout. Answers are cached 4055 * for an hour (see smartcalcs_cache_request()). 4056 * 4057 * @param WC_Order $order The recalculated order. 4058 * @return array|string|false|null Lookup result; ORDER_TAX_LOOKUP_BAD_ADDRESS when the 4059 * order address has a US state WooCommerce does not 4060 * list, or failed as one TaxJar cannot be asked 4061 * about; ORDER_TAX_LOOKUP_BAD_STORE_ADDRESS when it 4062 * failed on the store address; false when any other 4063 * request failed; null when the order has nothing 4064 * to ask about. 4065 */ 4066 private function lookup_order_taxes( $order ) { 4067 $default_type = $this->get_order_tax_location_type( $order ); 4068 $line_items = $this->get_backend_line_items( $order ); 4069 $shipping = (float) $order->get_shipping_total(); 4070 4071 $groups = array(); 4072 foreach ( $line_items as $item_key => $line_item ) { 4073 $type = 'base' === $default_type ? 'base' : ( $line_item['tax_location'] ?? $default_type ); 4074 $groups[ $type ][ $item_key ] = $line_item; 4075 } 4076 if ( $shipping > 0 && ! isset( $groups[ $default_type ] ) ) { 4077 $groups[ $default_type ] = array(); 4078 } 4079 4080 if ( ! $groups ) { 4081 return null; 4082 } 4083 4084 $billing = $order->get_address( 'billing' ); 4085 $result = array( 4086 'line_items' => $line_items, 4087 'rate_ids' => array(), 4088 'response_lines' => array(), 4089 'address' => $this->with_state_code( $this->get_order_tax_address( $billing, $order->get_address( 'shipping' ), $default_type ) ), 4090 ); 4091 4092 foreach ( $groups as $type => $items ) { 4093 $address = $this->with_state_code( $this->get_order_tax_address( $billing, $order->get_address( 'shipping' ), $type ) ); 4094 4095 // calculate_tax() would take it for another state's address and answer "no 4096 // tax" without asking. Keep the order's tax instead. 4097 if ( $this->has_unknown_us_state( $address ) ) { 4098 return self::ORDER_TAX_LOOKUP_BAD_ADDRESS; 4099 } 4100 4101 $taxes = $this->calculate_tax( 4102 array_merge( 4103 $address->to_legacy_options(), 4104 array( 4105 'shipping_amount' => $type === $default_type ? $shipping : 0, 4106 'line_items' => $items, 4107 ) 4108 ) 4109 ); 4110 4111 // calculate_tax() answers false for a failed request and for an address it 4112 // refused to send alike. Tell them apart here, after the fact, so what it 4113 // does with every other address stays exactly as at checkout. 4114 if ( false === $taxes ) { 4115 if ( ! $this->is_sendable_order_tax_address( $address ) ) { 4116 return self::ORDER_TAX_LOOKUP_BAD_ADDRESS; 4117 } 4118 4119 return $this->is_sendable_store_tax_address() ? false : self::ORDER_TAX_LOOKUP_BAD_STORE_ADDRESS; 4120 } 4121 4122 // Keys are canonical line item ids, unique across groups, plus 'shipping', 4123 // which only the default group sends. 4124 $result['rate_ids'] += $taxes['rate_ids']; 4125 $result['response_lines'] += $taxes['line_items']; 4126 } 4127 4128 return $result; 4129 } 4130 4131 /** 4132 * The address with a state name replaced by the state's code. 4133 * 4134 * The REST API accepts "ISO code or name" for the state and saves it as sent, so an 4135 * order can carry "Michigan" where checkout would send "MI". Matched case-insensitively 4136 * against WooCommerce's state names, as the Store API's ValidationUtils::format_state() 4137 * does. Only the address sent to TaxJar changes; the order keeps what it was given. 4138 * 4139 * @param Address $address The address the order is taxed at. 4140 * @return Address 4141 */ 4142 private function with_state_code( Address $address ) { 4143 $states = WC()->countries->get_states( $address->country() ); 4144 4145 if ( ! is_array( $states ) || '' === $address->state() || isset( $states[ $address->state() ] ) ) { 4146 return $address; 4147 } 4148 4149 $codes = array_flip( array_map( 'wc_strtoupper', $states ) ); 4150 $state = wc_strtoupper( $address->state() ); 4151 4152 if ( ! isset( $codes[ $state ] ) ) { 4153 return $address; 4154 } 4155 4156 return Address::from_options( array_merge( $address->to_legacy_options(), array( 'to_state' => (string) $codes[ $state ] ) ) ); 4157 } 4158 4159 /** 4160 * Whether a US address has no state, or one WooCommerce does not list. 4161 * 4162 * Such an address would lose its tax without a request: calculate_tax() compares the 4163 * order's state with the store's and answers "no tax" for any mismatch. 4164 * 4165 * @param Address $address The address the order is taxed at, after with_state_code(). 4166 * @return bool 4167 */ 4168 private function has_unknown_us_state( Address $address ) { 4169 if ( 'US' !== $address->country() ) { 4170 return false; 4171 } 4172 4173 $states = WC()->countries->get_states( 'US' ); 4174 4175 return is_array( $states ) && $states && ! isset( $states[ $address->state() ] ); 4176 } 4177 4178 /** 4179 * Whether an order's tax address has what TaxJar needs to be asked about it. 4180 * 4181 * Mirrors the destination checks that calculate_tax() and validate_taxjar_request() 4182 * make before sending anything: a country, a postcode outside the VAT countries, a 4183 * state in the US and Canada, and a well-formed US ZIP code when it is 5 or 10 4184 * characters long. Kept no stricter than those, so an address that was sent is 4185 * never blamed for a request that failed. 4186 * 4187 * @param Address $address The address the order is taxed at. 4188 * @return bool 4189 */ 4190 private function is_sendable_order_tax_address( Address $address ) { 4191 $country = $address->country(); 4192 $postcode = $address->postcode(); 4193 4194 if ( '' === $country ) { 4195 return false; 4196 } 4197 4198 if ( '' === $postcode && ! in_array( $country, WC()->countries->get_vat_countries(), true ) ) { 4199 return false; 4200 } 4201 4202 if ( in_array( $country, array( 'US', 'CA' ), true ) && '' === $address->state() ) { 4203 return false; 4204 } 4205 4206 return 'US' !== $country || ! in_array( strlen( $postcode ), array( 5, 10 ), true ) || WC_Validation::is_postcode( $postcode, 'US' ); 4207 } 4208 4209 /** 4210 * Whether the store address has what TaxJar needs to be asked from it. 4211 * 4212 * Mirrors the origin checks that calculate_tax() and validate_taxjar_request() make 4213 * before sending anything: a country, and a valid ZIP code for a US store. Reads the 4214 * store settings, not a nexus address a filter may send in their place. 4215 * 4216 * @return bool 4217 */ 4218 private function is_sendable_store_tax_address() { 4219 $store = Address::from_store_settings( $this->get_store_settings() ); 4220 4221 if ( '' === $store->country() ) { 4222 return false; 4223 } 4224 4225 return 'US' !== $store->country() || WC_Validation::is_postcode( $store->postcode(), 'US' ); 4226 } 4227 4228 /** 4229 * Tax an order at the rates TaxJar just returned for it. 4230 * 4231 * Every line is re-taxed: the old rates belonged to the old address, or there were 4232 * none. A fee is taxed at the rates of a product in its tax class, or keeps the tax 4233 * WC gave it when no such product was looked up. The address and 4234 * the change are recorded in a note when the address changed or the tax moved. 4235 * 4236 * An answer without rates means the store has no nexus at the address. The tax WC 4237 * calculated from the rate table is kept then, as checkout and the admin Recalculate 4238 * keep it, so a rate the merchant added for that state is still charged. 4239 * 4240 * @param WC_Order $order The recalculated order. 4241 * @param array $lookup Result of lookup_order_taxes(). 4242 * @param array $snapshot Snapshot taken before the recalculation. 4243 */ 4244 private function apply_looked_up_order_taxes( $order, array $lookup, array $snapshot ) { 4245 if ( empty( $lookup['rate_ids'] ) ) { 4246 if ( null !== $snapshot['address_before'] ) { 4247 $this->add_order_tax_note( 4248 $order, 4249 sprintf( 4250 /* translators: 1: address the tax was calculated for, 2: tax before, 3: tax after. */ 4251 __( 'Tax updated for %1$s from the store\'s tax rates: %2$s → %3$s.', 'woocommerce-services' ), 4252 $this->format_order_tax_address( $lookup['address'] ), 4253 wc_price( (float) $snapshot['cart_tax'] + (float) $snapshot['shipping_tax'], array( 'currency' => $order->get_currency() ) ), 4254 wc_price( (float) $order->get_cart_tax() + (float) $order->get_shipping_tax(), array( 'currency' => $order->get_currency() ) ) 4255 ) 4256 ); 4257 } 4258 return; 4259 } 4260 4261 $line_rates = array(); 4262 $rates_by_class = array(); 4263 foreach ( $order->get_items() as $item_key => $item ) { 4264 $line_id = $lookup['line_items'][ $item_key ]['id'] ?? null; 4265 if ( null === $line_id || empty( $lookup['rate_ids'][ $line_id ] ) ) { 4266 continue; 4267 } 4268 4269 $line_rates[ $item_key ] = $this->get_looked_up_rates( $lookup['rate_ids'][ $line_id ], $lookup['response_lines'][ $line_id ] ?? null ); 4270 4271 if ( ! isset( $rates_by_class[ $item->get_tax_class() ] ) ) { 4272 $rates_by_class[ $item->get_tax_class() ] = $line_rates[ $item_key ]; 4273 } 4274 } 4275 $shipping_rates = empty( $lookup['rate_ids']['shipping'] ) ? array() : $this->get_looked_up_rates( $lookup['rate_ids']['shipping'] ); 4276 4277 $percents = array(); 4278 $untaxed = array(); 4279 foreach ( $order->get_items( array( 'line_item', 'fee', 'shipping' ) ) as $item_key => $item ) { 4280 if ( 'taxable' !== $item->get_tax_status() ) { 4281 $item->set_taxes( false ); 4282 $this->run_order_item_tax_hook( $item ); 4283 continue; 4284 } 4285 4286 if ( 'shipping' === $item->get_type() ) { 4287 $rates = $shipping_rates; 4288 } elseif ( 'fee' === $item->get_type() ) { 4289 $rates = $rates_by_class[ $item->get_tax_class() ] ?? array(); 4290 4291 // No product in the fee's tax class was looked up: keep the tax WC gave it 4292 // from the rate table, as checkout and the admin Recalculate do for fees. 4293 if ( ! $rates ) { 4294 $fee_taxes = $item->get_taxes(); 4295 if ( empty( $fee_taxes['total'] ) || ! array_filter( $fee_taxes['total'] ) ) { 4296 $untaxed[] = $item->get_name(); 4297 } 4298 continue; 4299 } 4300 } else { 4301 $rates = $line_rates[ $item_key ] ?? array(); 4302 } 4303 4304 if ( ! $rates ) { 4305 $item->set_taxes( false ); 4306 $this->run_order_item_tax_hook( $item ); 4307 continue; 4308 } 4309 4310 $taxes = array( 'total' => WC_Tax::calc_tax( $item->get_total(), $rates, false ) ); 4311 if ( is_callable( array( $item, 'get_subtotal' ) ) ) { 4312 $taxes['subtotal'] = WC_Tax::calc_tax( $item->get_subtotal(), $rates, false ); 4313 } 4314 $item->set_taxes( $taxes ); 4315 $this->run_order_item_tax_hook( $item ); 4316 4317 foreach ( $rates as $rate_id => $rate ) { 4318 $percents[ $rate_id ] = array( 'rate_percent' => $rate['rate'] ); 4319 } 4320 } 4321 4322 // The rate table holds one row per rate and tax class, so two products taxed at 4323 // different rates share a row; the tax line shows the rate TaxJar returned. 4324 $this->rebuild_order_tax_totals( $order, $percents ); 4325 4326 if ( $untaxed ) { 4327 $this->add_order_tax_note( 4328 $order, 4329 sprintf( 4330 /* translators: %s: comma-separated names of order items. */ 4331 __( 'No tax was added for %s, because the order has no recorded tax rate for it. Check the tax on this order.', 'woocommerce-services' ), 4332 implode( ', ', array_map( 'wp_strip_all_tags', $untaxed ) ) 4333 ) 4334 ); 4335 } 4336 4337 $old_tax = (float) $snapshot['cart_tax'] + (float) $snapshot['shipping_tax']; 4338 $new_tax = (float) $order->get_cart_tax() + (float) $order->get_shipping_tax(); 4339 4340 if ( null === $snapshot['address_before'] && ( empty( $snapshot['tax_lines'] ) || abs( $old_tax - $new_tax ) < 0.000001 ) ) { 4341 return; 4342 } 4343 4344 $this->add_order_tax_note( 4345 $order, 4346 sprintf( 4347 /* translators: 1: address the tax was calculated for, 2: tax before, 3: tax after. */ 4348 __( 'Tax recalculated with today\'s rates for %1$s: %2$s → %3$s.', 'woocommerce-services' ), 4349 $this->format_order_tax_address( $lookup['address'] ), 4350 wc_price( $old_tax, array( 'currency' => $order->get_currency() ) ), 4351 wc_price( $new_tax, array( 'currency' => $order->get_currency() ) ) 4352 ) 4353 ); 4354 } 4355 4356 /** 4357 * An order's tax address on one line, for an order note. 4358 * 4359 * @param Address $address The address the order is taxed at. 4360 * @return string 4361 */ 4362 private function format_order_tax_address( Address $address ) { 4363 return implode( ', ', array_filter( array( $address->street(), $address->city(), $address->state(), $address->postcode(), $address->country() ) ) ); 4364 } 4365 4366 /** 4367 * Rates for WC_Tax::calc_tax() from rate ids TaxJar's answer was stored under. 4368 * 4369 * The percentage comes from the answer when it is given: it is what TaxJar charged 4370 * this line, while the shared row may since hold another line's rate. 4371 * 4372 * @param array $rate_ids Rate ids, in the order the answer's rates were stored. 4373 * @param object|null $response_line The answer's breakdown line for this item. 4374 * @return array 4375 */ 4376 private function get_looked_up_rates( array $rate_ids, $response_line = null ) { 4377 $percents = array(); 4378 if ( is_object( $response_line ) ) { 4379 // Same filter get_itemized_tax_rates() applies when it stores them. 4380 foreach ( (array) $response_line as $name => $value ) { 4381 if ( 'combined_tax_rate' === $name || false === strpos( $name, '_tax_rate' ) ) { 4382 continue; 4383 } 4384 $percents[] = round( (float) $value * 100, 4 ); 4385 } 4386 } 4387 4388 $rates = array(); 4389 foreach ( array_values( $rate_ids ) as $index => $rate_id ) { 4390 $row = WC_Tax::_get_tax_rate( absint( $rate_id ) ); 4391 if ( ! $row ) { 4392 continue; 4393 } 4394 $rates[ absint( $rate_id ) ] = array( 4395 'rate' => $percents[ $index ] ?? (float) $row['tax_rate'], 4396 'label' => $row['tax_rate_name'], 4397 'shipping' => 'yes', 4398 'compound' => ! empty( $row['tax_rate_compound'] ) ? 'yes' : 'no', 4399 ); 4400 } 4401 4402 return $rates; 4403 } 2775 4404 } -
woocommerce-services/trunk/classes/class-wc-connect-tracks.php
r3452871 r3732427 94 94 } 95 95 96 $data['_via_ua'] = isset( $_SERVER['HTTP_USER_AGENT'] ) ? wc_clean( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';97 $data['_via_ip'] = isset( $_SERVER['REMOTE_ADDR'] ) ? wc_clean( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';98 $data['_lg'] = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? wc_clean( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : '';96 $data['_via_ua'] = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; 97 $data['_via_ip'] = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; 98 $data['_lg'] = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : ''; 99 99 $data['blog_url'] = get_option( 'siteurl' ); 100 100 $data['blog_id'] = $jetpack_blog_id; … … 116 116 } 117 117 } 118 119 118 } 120 119 -
woocommerce-services/trunk/classes/class-wc-rest-connect-account-settings-controller.php
r2498257 r3732427 58 58 'save_failed', 59 59 sprintf( 60 /* translators: %s: error message */ 60 61 __( 'Unable to update settings. %s', 'woocommerce-services' ), 61 62 $result->get_error_message() -
woocommerce-services/trunk/classes/class-wc-rest-connect-base-controller.php
r2556587 r3732427 153 153 } 154 154 155 /** 156 * Get the order a label route acts on, before anything is written or sent upstream. 157 * 158 * Anything that is not an order, such as a page, a product or a refund, is not found. 159 * 160 * @param int|string $order_id Order ID from the route. 161 * 162 * @return WC_Order|WP_Error 163 */ 164 protected function get_order_for_label_request( $order_id ) { 165 $order_id = (int) $order_id; 166 $order = $order_id > 0 ? wc_get_order( $order_id ) : false; 167 168 if ( ! $order instanceof WC_Order ) { 169 return new WP_Error( 'not_found', __( 'Order not found', 'woocommerce-services' ), array( 'status' => 404 ) ); 170 } 171 172 return $order; 173 } 174 175 /** 176 * Whether a shipping label is one of the order's own labels. 177 * 178 * @param WC_Order $order Order from get_order_for_label_request(). 179 * @param int|string $label_id Label ID from the route. 180 * 181 * @return bool 182 */ 183 protected function is_label_on_order( WC_Order $order, $label_id ) { 184 foreach ( $this->settings_store->get_label_order_meta_data( $order->get_id() ) as $label ) { 185 if ( is_array( $label ) && isset( $label['label_id'] ) && (int) $label['label_id'] === (int) $label_id ) { 186 return true; 187 } 188 } 189 190 return false; 191 } 155 192 } -
woocommerce-services/trunk/classes/class-wc-rest-connect-packages-controller.php
r2498257 r3732427 65 65 if ( ! empty( $custom_packages ) ) { 66 66 // Validate that the new custom packages have unique names. 67 $map_package_name = function ( $package ) {67 $map_package_name = function ( $package ) { 68 68 return $package['name']; 69 69 }; … … 75 75 $error = array( 76 76 'code' => 'duplicate_custom_package_names', 77 'message' => __( 'The new custom package names are not unique.' ),77 'message' => __( 'The new custom package names are not unique.', 'woocommerce-services' ), 78 78 'data' => array( 'package_names' => array_values( $duplicate_package_names ) ), 79 79 ); … … 89 89 $error = array( 90 90 'code' => 'duplicate_custom_package_names_of_existing_packages', 91 'message' => __( 'At least one of the new custom packages has the same name as existing packages.' ),91 'message' => __( 'At least one of the new custom packages has the same name as existing packages.', 'woocommerce-services' ), 92 92 'data' => array( 'package_names' => array_values( $duplicate_package_names ) ), 93 93 ); … … 116 116 $error = array( 117 117 'code' => 'duplicate_predefined_package_names', 118 'message' => __( 'The new predefined package names are not unique.' ),118 'message' => __( 'The new predefined package names are not unique.', 'woocommerce-services' ), 119 119 'data' => array( 'package_names_by_carrier' => $duplicate_package_names_by_carrier ), 120 120 ); … … 137 137 $error = array( 138 138 'code' => 'duplicate_predefined_package_names_of_existing_packages', 139 'message' => __( 'At least one of the new predefined packages has the same name as existing packages.' ),139 'message' => __( 'At least one of the new predefined packages has the same name as existing packages.', 'woocommerce-services' ), 140 140 'data' => array( 'package_names_by_carrier' => $duplicate_package_names_by_carrier ), 141 141 ); -
woocommerce-services/trunk/classes/class-wc-rest-connect-services-controller.php
r2498257 r3732427 90 90 'validation_failed', 91 91 sprintf( 92 /* translators: %s: validation error message */ 92 93 __( 'Unable to update service settings. Validation failed. %s', 'woocommerce-services' ), 93 94 $validation_result->get_error_message() … … 101 102 return new WP_REST_Response( array( 'success' => true ), 200 ); 102 103 } 103 104 104 } -
woocommerce-services/trunk/classes/class-wc-rest-connect-shipping-label-controller.php
r3298671 r3732427 43 43 44 44 public function post( $request ) { 45 // Check the order before a label is bought for it. 46 $order = $this->get_order_for_label_request( $request['order_id'] ); 47 if ( is_wp_error( $order ) ) { 48 return $order; 49 } 50 51 // The Connect server has always received the order ID as a string. 45 52 $settings = $request->get_json_params(); 46 $order_id = $request['order_id'];53 $order_id = (string) $order->get_id(); 47 54 $settings['order_id'] = $order_id; 48 55 … … 147 154 $product_names[] = $product->get_title(); 148 155 } else { 149 $order = wc_get_order( $order_id );150 156 $product_names[] = WC_Connect_Utils::get_product_name_from_order( $product_id, $order ); 151 157 } … … 158 164 } 159 165 160 $this->settings_store->add_labels_to_order( $order _id, $purchased_labels_meta );166 $this->settings_store->add_labels_to_order( $order->get_id(), $purchased_labels_meta ); 161 167 162 168 return array( -
woocommerce-services/trunk/classes/class-wc-rest-connect-shipping-label-refund-controller.php
r2498257 r3732427 13 13 14 14 public function post( $request ) { 15 $order = $this->get_order_for_label_request( $request['order_id'] ); 16 if ( is_wp_error( $order ) ) { 17 return $order; 18 } 19 20 // Never refund a label that is not this order's. 21 if ( ! $this->is_label_on_order( $order, $request['label_id'] ) ) { 22 return new WP_Error( 'not_found', __( 'Shipping label not found', 'woocommerce-services' ), array( 'status' => 404 ) ); 23 } 24 15 25 $response = $this->api_client->send_shipping_label_refund_request( $request['label_id'] ); 16 26 … … 38 48 'refund' => $response->refund, 39 49 ); 40 $this->settings_store->update_label_order_meta_data( $ request['order_id'], $label_refund );50 $this->settings_store->update_label_order_meta_data( $order->get_id(), $label_refund ); 41 51 42 52 return array( -
woocommerce-services/trunk/classes/class-wc-rest-connect-shipping-label-status-controller.php
r2510126 r3732427 13 13 14 14 public function get( $request ) { 15 $order = $this->get_order_for_label_request( $request['order_id'] ); 16 if ( is_wp_error( $order ) ) { 17 return $order; 18 } 19 15 20 $label_ids = explode( ',', $request['label_ids'] ); 16 $labels = array(); 21 22 // Every label must be this order's before any status is fetched. 23 foreach ( $label_ids as $label_id ) { 24 if ( ! $this->is_label_on_order( $order, $label_id ) ) { 25 return new WP_Error( 'not_found', __( 'Shipping label not found', 'woocommerce-services' ), array( 'status' => 404 ) ); 26 } 27 } 28 29 $labels = array(); 17 30 foreach ( $label_ids as $label_id ) { 18 31 $response = $this->api_client->get_label_status( $label_id ); … … 27 40 } 28 41 29 $label = $this->settings_store->update_label_order_meta_data( $ request['order_id'], $response->label );42 $label = $this->settings_store->update_label_order_meta_data( $order->get_id(), $response->label ); 30 43 $labels[] = $label; 31 44 } -
woocommerce-services/trunk/classes/class-wc-rest-connect-shipping-rates-controller.php
r2498257 r3732427 34 34 35 35 /** 36 * Whether an address from the request is an array with a street, so saving it cannot blank 37 * the stored origin or the order's shipping street. 38 * 39 * @param mixed $address Address from the request. 40 * 41 * @return bool 42 */ 43 private function has_street_address( $address ) { 44 return is_array( $address ) && isset( $address['address'] ) && is_string( $address['address'] ) && '' !== trim( $address['address'] ); 45 } 46 47 /** 48 * Whether an ID is a product on one of the order's line items, as product or variation. 49 * 50 * @param WC_Order $order Order the rates are for. 51 * @param string $product_id Product ID from the request. 52 * 53 * @return bool 54 */ 55 private function is_product_on_order( WC_Order $order, $product_id ) { 56 if ( ! ctype_digit( $product_id ) || 0 === (int) $product_id || ! wc_get_product( (int) $product_id ) ) { 57 return false; 58 } 59 60 return false !== WC_Connect_Utils::get_line_item_from_order( (int) $product_id, $order ); 61 } 62 63 /** 36 64 * 37 65 * @param WP_REST_Request $request - See WC_Connect_API_Client::get_label_rates() … … 39 67 */ 40 68 public function post( $request ) { 41 $payload = $request->get_json_params(); 69 $order = $this->get_order_for_label_request( $request['order_id'] ); 70 if ( is_wp_error( $order ) ) { 71 return $order; 72 } 73 74 $payload = $request->get_json_params(); 75 76 // Check the body before anything is saved, so a bad request changes nothing. 77 if ( ! is_array( $payload ) 78 || ! isset( $payload['origin'], $payload['destination'], $payload['packages'] ) 79 || ! $this->has_street_address( $payload['origin'] ) 80 || ! $this->has_street_address( $payload['destination'] ) 81 || ! is_array( $payload['packages'] ) 82 || count( array_filter( $payload['packages'], 'is_array' ) ) !== count( $payload['packages'] ) ) { 83 return new WP_Error( 'bad_request', __( 'Bad request', 'woocommerce-services' ), array( 'status' => 400 ) ); 84 } 85 42 86 $payload['payment_method_id'] = $this->settings_store->get_selected_payment_method_id(); 43 $order_id = $ request['order_id'];87 $order_id = $order->get_id(); 44 88 45 89 // This is the earliest point in the printing label flow where we are sure that … … 55 99 } 56 100 foreach ( $package['items'] as $index => $item ) { 57 if ( ! isset( $updated_product_ids[ $item['product_id'] ] ) ) { 58 $updated_product_ids[ $item['product_id'] ] = true; 101 $product_id = isset( $item['product_id'] ) && is_scalar( $item['product_id'] ) ? (string) $item['product_id'] : ''; 102 103 if ( ! isset( $updated_product_ids[ $product_id ] ) ) { 104 $updated_product_ids[ $product_id ] = true; 105 106 // Only products on this order get customs info; skip any other ID. 107 if ( ! $this->is_product_on_order( $order, $product_id ) ) { 108 $this->logger->log( 109 sprintf( 'Skipped customs info for ID %s: not a product on order %d.', wp_json_encode( $product_id ), $order_id ), 110 __CLASS__ 111 ); 112 continue; 113 } 114 59 115 update_post_meta( 60 $item['product_id'],116 (int) $product_id, 61 117 'wc_connect_customs_info', 62 118 array( -
woocommerce-services/trunk/classes/class-wc-rest-connect-tos-controller.php
r2498257 r3732427 42 42 43 43 /** 44 * Validate the requester's permissions 44 * Validate the requester's permissions: the capabilities below, and only a user who 45 * may accept the Terms of Service (the connection owner, or anyone in offline mode). 46 * 47 * @param WP_REST_Request $request Request being authorized. 48 * @return bool 45 49 */ 46 50 public function check_permission( $request ) { 47 51 return current_user_can( 'manage_woocommerce' ) && 48 52 current_user_can( 'install_plugins' ) && 49 current_user_can( 'activate_plugins' ); 53 current_user_can( 'activate_plugins' ) && 54 class_exists( 'WC_Connect_Nux' ) && 55 WC_Connect_Nux::current_user_can_accept_tos(); 50 56 } 51 57 } -
woocommerce-services/trunk/classes/wc-api-dev/class-wc-rest-dev-data-continents-controller.php
r3034437 r3732427 76 76 'args' => array( 77 77 'continent' => array( 78 'description' => __( '2 character continent code.', 'woocommerce ' ),78 'description' => __( '2 character continent code.', 'woocommerce-services' ), 79 79 'type' => 'string', 80 80 ), … … 116 116 $data = $this->continents->get_continent( strtoupper( $request['location'] ), $request ); 117 117 if ( empty( $data ) ) { 118 return new WP_Error( 'woocommerce_rest_data_invalid_location', __( 'There are no locations matching these parameters.', 'woocommerce ' ), array( 'status' => 404 ) );118 return new WP_Error( 'woocommerce_rest_data_invalid_location', __( 'There are no locations matching these parameters.', 'woocommerce-services' ), array( 'status' => 404 ) ); 119 119 } 120 120 return $this->prepare_item_for_response( $data, $request ); … … 181 181 'code' => array( 182 182 'type' => 'string', 183 'description' => __( '2 character continent code.', 'woocommerce ' ),183 'description' => __( '2 character continent code.', 'woocommerce-services' ), 184 184 'context' => array( 'view' ), 185 185 'readonly' => true, … … 187 187 'name' => array( 188 188 'type' => 'string', 189 'description' => __( 'Full name of continent.', 'woocommerce ' ),189 'description' => __( 'Full name of continent.', 'woocommerce-services' ), 190 190 'context' => array( 'view' ), 191 191 'readonly' => true, … … 193 193 'countries' => array( 194 194 'type' => 'array', 195 'description' => __( 'List of countries on this continent.', 'woocommerce ' ),195 'description' => __( 'List of countries on this continent.', 'woocommerce-services' ), 196 196 'context' => array( 'view' ), 197 197 'readonly' => true, … … 203 203 'code' => array( 204 204 'type' => 'string', 205 'description' => __( 'ISO3166 alpha-2 country code.', 'woocommerce ' ),205 'description' => __( 'ISO3166 alpha-2 country code.', 'woocommerce-services' ), 206 206 'context' => array( 'view' ), 207 207 'readonly' => true, … … 209 209 'currency_code' => array( 210 210 'type' => 'string', 211 'description' => __( 'Default ISO4127 alpha-3 currency code for the country.', 'woocommerce ' ),211 'description' => __( 'Default ISO4127 alpha-3 currency code for the country.', 'woocommerce-services' ), 212 212 'context' => array( 'view' ), 213 213 'readonly' => true, … … 215 215 'currency_pos' => array( 216 216 'type' => 'string', 217 'description' => __( 'Currency symbol position for this country.', 'woocommerce ' ),217 'description' => __( 'Currency symbol position for this country.', 'woocommerce-services' ), 218 218 'context' => array( 'view' ), 219 219 'readonly' => true, … … 221 221 'decimal_sep' => array( 222 222 'type' => 'string', 223 'description' => __( 'Decimal separator for displayed prices for this country.', 'woocommerce ' ),223 'description' => __( 'Decimal separator for displayed prices for this country.', 'woocommerce-services' ), 224 224 'context' => array( 'view' ), 225 225 'readonly' => true, … … 227 227 'dimension_unit' => array( 228 228 'type' => 'string', 229 'description' => __( 'The unit lengths are defined in for this country.', 'woocommerce ' ),229 'description' => __( 'The unit lengths are defined in for this country.', 'woocommerce-services' ), 230 230 'context' => array( 'view' ), 231 231 'readonly' => true, … … 233 233 'name' => array( 234 234 'type' => 'string', 235 'description' => __( 'Full name of country.', 'woocommerce ' ),235 'description' => __( 'Full name of country.', 'woocommerce-services' ), 236 236 'context' => array( 'view' ), 237 237 'readonly' => true, … … 239 239 'num_decimals' => array( 240 240 'type' => 'integer', 241 'description' => __( 'Number of decimal points shown in displayed prices for this country.', 'woocommerce ' ),241 'description' => __( 'Number of decimal points shown in displayed prices for this country.', 'woocommerce-services' ), 242 242 'context' => array( 'view' ), 243 243 'readonly' => true, … … 245 245 'states' => array( 246 246 'type' => 'array', 247 'description' => __( 'List of states in this country.', 'woocommerce ' ),247 'description' => __( 'List of states in this country.', 'woocommerce-services' ), 248 248 'context' => array( 'view' ), 249 249 'readonly' => true, … … 255 255 'code' => array( 256 256 'type' => 'string', 257 'description' => __( 'State code.', 'woocommerce ' ),257 'description' => __( 'State code.', 'woocommerce-services' ), 258 258 'context' => array( 'view' ), 259 259 'readonly' => true, … … 261 261 'name' => array( 262 262 'type' => 'string', 263 'description' => __( 'Full name of state.', 'woocommerce ' ),263 'description' => __( 'Full name of state.', 'woocommerce-services' ), 264 264 'context' => array( 'view' ), 265 265 'readonly' => true, … … 270 270 'thousand_sep' => array( 271 271 'type' => 'string', 272 'description' => __( 'Thousands separator for displayed prices in this country.', 'woocommerce ' ),272 'description' => __( 'Thousands separator for displayed prices in this country.', 'woocommerce-services' ), 273 273 'context' => array( 'view' ), 274 274 'readonly' => true, … … 276 276 'weight_unit' => array( 277 277 'type' => 'string', 278 'description' => __( 'The unit weights are defined in for this country.', 'woocommerce ' ),278 'description' => __( 'The unit weights are defined in for this country.', 'woocommerce-services' ), 279 279 'context' => array( 'view' ), 280 280 'readonly' => true, -
woocommerce-services/trunk/classes/wc-api-dev/class-wc-rest-dev-data-controller.php
r2498257 r3732427 67 67 public function get_items_permissions_check( $request ) { 68 68 if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { 69 return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce ' ), array( 'status' => rest_authorization_required_code() ) );69 return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'woocommerce-services' ), array( 'status' => rest_authorization_required_code() ) ); 70 70 } 71 71 … … 81 81 public function get_item_permissions_check( $request ) { 82 82 if ( ! wc_rest_check_manager_permissions( 'settings', 'read' ) ) { 83 return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view this resource.', 'woocommerce ' ), array( 'status' => rest_authorization_required_code() ) );83 return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view this resource.', 'woocommerce-services' ), array( 'status' => rest_authorization_required_code() ) ); 84 84 } 85 85 … … 99 99 array( 100 100 'slug' => 'continents', 101 'description' => __( 'List of supported continents, countries, and states.', 'woocommerce ' ),101 'description' => __( 'List of supported continents, countries, and states.', 'woocommerce-services' ), 102 102 ), 103 103 array( 104 104 'slug' => 'countries', 105 'description' => __( 'List of supported states in a given country.', 'woocommerce ' ),105 'description' => __( 'List of supported states in a given country.', 'woocommerce-services' ), 106 106 ), 107 107 array( 108 108 'slug' => 'currencies', 109 'description' => __( 'List of supported currencies.', 'woocommerce ' ),109 'description' => __( 'List of supported currencies.', 'woocommerce-services' ), 110 110 ), 111 111 ); … … 174 174 'properties' => array( 175 175 'slug' => array( 176 'description' => __( 'Data resource ID.', 'woocommerce ' ),176 'description' => __( 'Data resource ID.', 'woocommerce-services' ), 177 177 'type' => 'string', 178 178 'context' => array( 'view' ), … … 180 180 ), 181 181 'description' => array( 182 'description' => __( 'Data resource description.', 'woocommerce ' ),182 'description' => __( 'Data resource description.', 'woocommerce-services' ), 183 183 'type' => 'string', 184 184 'context' => array( 'view' ), -
woocommerce-services/trunk/i18n/languages/woocommerce-services.pot
r3707890 r3732427 5 5 "Project-Id-Version: _s woocommerce-services\n" 6 6 "Report-Msgid-Bugs-To: \n" 7 "POT-Creation-Date: 2026- 09-22T17:54:57.634Z\n"7 "POT-Creation-Date: 2026-10-07T11:06:28.728Z\n" 8 8 "MIME-Version: 1.0\n" 9 9 "Content-Type: text/plain; charset=UTF-8\n" … … 110 110 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 111 111 #: dist/chunks/13f51058cf091ad1e827.min.js:1 112 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1112 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 113 113 msgid "Create shipping label" 114 114 msgid_plural "Create shipping labels" … … 117 117 118 118 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 119 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1119 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 120 120 msgid "Track Package" 121 121 msgid_plural "Track Packages" … … 124 124 125 125 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 126 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1126 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 127 127 msgid "Create new label" 128 128 msgstr "" 129 129 130 130 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 131 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1131 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 132 132 msgid "Connection error: unable to create label at this time" 133 133 msgstr "" 134 134 135 135 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 136 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1136 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 137 137 msgid "%(itemCount)d item was fulfilled on {{span}}%(createdDate)s{{/span}}" 138 138 msgid_plural "" … … 142 142 143 143 #: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1 144 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1144 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 145 145 msgid "%(itemCount)d item is ready to be fulfilled" 146 146 msgid_plural "%(itemCount)d items are ready to be fulfilled" … … 804 804 #: dist/chunks/13f51058cf091ad1e827.min.js:1 805 805 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 806 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1806 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 807 807 msgid "Try WooCommerce Shipping" 808 808 msgstr "" … … 810 810 #: dist/chunks/13f51058cf091ad1e827.min.js:1 811 811 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 812 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1812 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 813 813 msgid "A new WooCommerce Shipping experience is now available" 814 814 msgstr "" … … 816 816 #: dist/chunks/13f51058cf091ad1e827.min.js:1 817 817 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 818 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1818 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 819 819 msgid "" 820 820 "We'll ensure a seamless transition by allowing you to migrate all your " … … 824 824 #: dist/chunks/13f51058cf091ad1e827.min.js:1 825 825 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 826 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1826 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 827 827 msgid "Here's what you can expect from the new shipping experience:" 828 828 msgstr "" … … 830 830 #: dist/chunks/13f51058cf091ad1e827.min.js:1 831 831 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 832 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1832 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 833 833 msgid "A seamless transition" 834 834 msgstr "" … … 836 836 #: dist/chunks/13f51058cf091ad1e827.min.js:1 837 837 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 838 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1838 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 839 839 msgid "" 840 840 "Automatically migrate all your compatible settings and shipment history to " … … 844 844 #: dist/chunks/13f51058cf091ad1e827.min.js:1 845 845 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 846 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1846 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 847 847 msgid "Print and save" 848 848 msgstr "" … … 850 850 #: dist/chunks/13f51058cf091ad1e827.min.js:1 851 851 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 852 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1852 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 853 853 msgid "" 854 854 "Speed up label creation with a streamlined process to print and save your " … … 858 858 #: dist/chunks/13f51058cf091ad1e827.min.js:1 859 859 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 860 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1860 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 861 861 msgid "USPS and DHL Express" 862 862 msgstr "" … … 864 864 #: dist/chunks/13f51058cf091ad1e827.min.js:1 865 865 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 866 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1866 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 867 867 msgid "" 868 868 "Ship your productss using trusted shipping carriers like USPS and DHL " … … 872 872 #: dist/chunks/13f51058cf091ad1e827.min.js:1 873 873 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 874 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1874 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 875 875 msgid "Enhanced label purchase flow" 876 876 msgstr "" … … 878 878 #: dist/chunks/13f51058cf091ad1e827.min.js:1 879 879 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 880 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1880 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 881 881 msgid "" 882 882 "Experience a smoother label purchasing process with our updated interface." … … 885 885 #: dist/chunks/13f51058cf091ad1e827.min.js:1 886 886 #: dist/chunks/8d185b5886cb12db43a8.min.js:1 887 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7. 0.js:1887 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1 888 888 msgid "Maybe later" 889 889 msgstr "" -
woocommerce-services/trunk/readme.txt
r3707890 r3732427 7 7 Tested up to: 7.1 8 8 WC requires at least: 10.9 9 WC tested up to: 11. 110 Stable tag: 3.7. 09 WC tested up to: 11.2 10 Stable tag: 3.7.1 11 11 License: GPLv2 or later 12 12 License URI: http://www.gnu.org/licenses/gpl-2.0.html … … 71 71 == Changelog == 72 72 73 = 3.7.1 - 2026-10-07 = 74 * Add - Check a US store address when it changes, and show a notice in the admin when its state or ZIP code looks wrong or is missing, since either can make automated taxes charge the wrong tax or none. 75 * Fix - Stop the classic checkout from jumping to the top of the page and taking focus away from the ZIP code field when an invalid ZIP code is entered, which could loop endlessly with address autocomplete enabled. 76 * Fix - Stop a store address with a missing or badly formatted ZIP code from blocking checkout with "ZIP Code is not formatted correctly." The order now goes through, but its tax is not looked up: it gets only the tax rates saved by earlier lookups for the same address, or no tax. A notice in the admin asks you to fix the ZIP code. 77 * Fix - Send the street with the address when you click Recalculate on an order that has no shipping address, so tax is not calculated from the ZIP code alone. Recalculate also no longer freezes when the city contains a double quote. 78 * Fix - Stop automated tax lookups from overwriting a tax rate you set up for a whole state with no ZIP code or city, which could leave in-person and mobile app orders with no tax. 79 * Fix - Recalculating an order in the admin now charges the tax rates returned by the tax service, as checkout does. Other tax rates you set up for the same address are no longer added on top. 80 * Fix - Update an order's tax when its items, quantities, fees, shipping or coupons change through the REST API or the WooCommerce app, using the tax rates recorded when the order was placed. Since 3.6.8 the tax stayed at its original amount after these edits, and each change is now recorded in an order note. 81 * Fix - Stop putting tax back on an order that was made VAT exempt after it was placed. 82 * Fix - Look up the tax for the new address when an order's address changes through the REST API or the WooCommerce app. If the tax service cannot answer, the order keeps its tax and a note says so. 83 * Fix - Calculate tax with WooCommerce Tax for orders created through the REST API, the WooCommerce app or point of sale, instead of using whatever tax rates happened to be stored for that area. 84 * Fix - Keep the TaxJar product tax code when recalculating an order in the admin for tax classes whose name has more than one word, such as "Reduced rate 12345", so the order is taxed the same as at checkout. Products in the built-in "Zero rate" class also stay tax-free when the order is recalculated. 85 * Fix - Unslash and sanitize request values in the label reports, settings, migration survey and tracking code, and stop using print_r in log messages. 86 * Fix - Require a nonce on the setup banner's Terms of Service and dismiss links, so they cannot be triggered by loading a crafted URL. 87 * Fix - Stop Recalculate and saving an order's items in the admin from logging a PHP notice for every item and saving an extra tax line with no name, which other plugins could see before it was removed again. 88 * Fix - Stop REST requests the site would refuse from writing to the WooCommerce Tax log, and stop REST errors from replacing the error notice shown to store managers. Request bodies are shortened and only logged when debug logging is on. 89 * Fix - Prevent a fatal error in the tax calculation when custom code creates the TaxJar integration without a store notices notifier. 90 * Fix - Prevent a rare fatal error when WooCommerce Tax registers its Store API data on incomplete WooCommerce installations. 91 * Fix - Stop store admins other than the Jetpack connection owner from accepting the WooCommerce Tax Terms of Service through the REST API or the banner shown after connecting. 92 * Fix - Check that the order exists, and that a shipping label belongs to it, before buying a label, getting rates, checking a label's status or refunding a label. Customs details are only saved to products on that order. 93 * Tweak - Block direct access to the remaining class files and declare the license in the plugin header. 94 * Tweak - Include composer.json in the plugin package. 95 * Tweak - Bind the service IDs in the enabled services query with prepare() instead of escaping them by hand. 96 * Tweak - WooCommerce 11.2 Compatibility. 97 73 98 = 3.7.0 - 2026-09-22 = 74 99 * Fix - Prevent a fatal error that stopped WooCommerce Tax from being deactivated while WooCommerce was inactive. 75 100 * Tweak - Update the bundled JavaScript libraries. 76 101 * Tweak - Build the plugin's JavaScript on Node.js 24 and drop the wp-calypso submodule, keeping vendored copies of the modules it provided. 102 * Tweak - Load every translatable string from the plugin's own text domain and add translator notes to strings with placeholders. 77 103 78 104 = 3.6.16 - 2026-09-16 = -
woocommerce-services/trunk/src/Integrations/WooCommerceBlocksIntegration.php
r3635005 r3732427 127 127 $script_url, 128 128 $script_dependencies, 129 null, 129 null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Built asset whose URL carries the build hash, so the version is already in the file name. 130 130 true 131 131 ); -
woocommerce-services/trunk/src/StoreApi/StoreApiExtensionController.php
r3331570 r3732427 11 11 12 12 use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; 13 use Exception;13 use Throwable; 14 14 15 15 defined( 'ABSPATH' ) || exit; … … 93 93 ) 94 94 ); 95 } catch ( Exception $e ) { 96 wc_get_logger()->debug( 'Failed to register endpoint data for extension', array( 'error', $e->getMessage() ) ); 95 } catch ( Throwable $e ) { 96 wc_get_logger()->debug( 97 'Failed to register endpoint data for extension', 98 array( 99 'source' => 'woocommerce-services', 100 'error' => $e->getMessage(), 101 ) 102 ); 97 103 } 98 104 } … … 111 117 ) 112 118 ); 113 } catch ( Exception $e ) { 114 wc_get_logger()->debug( 'Failed to register update callback for extension', array( 'error', $e->getMessage() ) ); 119 } catch ( Throwable $e ) { 120 wc_get_logger()->debug( 121 'Failed to register update callback for extension', 122 array( 123 'source' => 'woocommerce-services', 124 'error' => $e->getMessage(), 125 ) 126 ); 115 127 } 116 128 } -
woocommerce-services/trunk/src/StoreNotices/StoreNoticesController.php
r3392766 r3732427 21 21 22 22 /** 23 * Selector of the classic checkout notices container, replaced by the update_order_review fragment. 24 * 25 * @var string 26 */ 27 const CLASSIC_CHECKOUT_NOTICES_SELECTOR = 'div.wcservices-checkout-notices'; 28 29 /** 23 30 * Notifier instance. 24 31 * … … 37 44 add_action( 'woocommerce_after_calculate_totals', array( $this, 'maybe_display_notices' ), 30 ); 38 45 add_filter( 'woocommerce_store_api_cart_errors', array( $this, 'add_store_api_cart_errors' ), 10, 2 ); 46 add_action( 'woocommerce_checkout_before_customer_details', array( $this, 'print_classic_checkout_notices_container' ) ); 47 add_filter( 'woocommerce_update_order_review_fragments', array( $this, 'add_update_order_review_fragment' ) ); 39 48 } 40 49 … … 47 56 } 48 57 58 // WooCommerce marks update_order_review as failed when the notice queue is not empty, and the classic 59 // checkout then blurs every field, which triggers another update: an endless loop. The notices are sent 60 // as a fragment instead (see add_update_order_review_fragment()). Placing the order still queues them. 61 if ( did_action( 'woocommerce_checkout_update_order_review' ) ) { 62 return; 63 } 64 49 65 $this->notifier->print_notices(); 50 66 $this->notifier::clear_notices(); 67 } 68 69 /** 70 * Print the classic checkout container that the update_order_review fragment fills with the notices. 71 */ 72 public function print_classic_checkout_notices_container() { 73 echo '<div class="wcservices-checkout-notices"></div>'; 74 } 75 76 /** 77 * Send the notices to the classic checkout as an update_order_review fragment. 78 * 79 * The container is replaced even when there are no notices, so a corrected address clears the message. 80 * 81 * @param array $fragments Checkout fragments. 82 * 83 * @return array 84 */ 85 public function add_update_order_review_fragment( $fragments ) { 86 if ( ! is_array( $fragments ) ) { 87 return $fragments; 88 } 89 90 $fragments[ self::CLASSIC_CHECKOUT_NOTICES_SELECTOR ] = '<div class="wcservices-checkout-notices">' . $this->get_notices_html() . '</div>'; 91 $this->notifier::clear_notices(); 92 93 return $fragments; 94 } 95 96 /** 97 * Render the notices with the WooCommerce notice templates. 98 * 99 * @return string 100 */ 101 private function get_notices_html(): string { 102 $html = ''; 103 104 foreach ( StoreNoticesNotifier::get_notices() as $type => $notices ) { 105 // The types wc_print_notices() renders by default, so the fragment shows what the notice queue would. 106 if ( ! in_array( $type, array( 'error', 'success', 'notice' ), true ) ) { 107 continue; 108 } 109 110 foreach ( $notices as $notice ) { 111 $html .= wc_print_notice( $this->notifier->maybe_get_formatted_message( $notice['message'], $notice['data'] ), $type, array(), true ); 112 } 113 } 114 115 return $html; 51 116 } 52 117 -
woocommerce-services/trunk/src/StoreNotices/StoreNoticesNotifier.php
r3331570 r3732427 132 132 */ 133 133 public function has_notice( string $message, string $type = 'notice', array $data = array(), string $group = '' ): bool { 134 if ( ! self::wc_session_exists() ) { 135 return false; 136 } 137 134 138 $notices = WC()->session->get( self::WC_SESSION_KEY, array() ); 135 139 -
woocommerce-services/trunk/src/Tax/Address.php
r3640567 r3732427 38 38 39 39 use WC_Customer; 40 use WC_Order; 40 41 use WP_Error; 41 42 … … 267 268 268 269 /** 270 * Build from an order's saved billing or shipping address. 271 * 272 * @param WC_Order $order Order to read. 273 * @param string $type 'billing' or 'shipping'. Anything else reads billing. 274 * @return self 275 */ 276 public static function from_order( WC_Order $order, string $type ): self { 277 // Clean the same way from_post_request() does, so a saved address compares 278 // equal to the same address arriving through the request. 279 if ( 'shipping' === $type ) { 280 return new self( 281 (string) wc_clean( $order->get_shipping_country() ), 282 (string) wc_clean( $order->get_shipping_state() ), 283 (string) wc_clean( $order->get_shipping_postcode() ), 284 (string) wc_clean( $order->get_shipping_city() ), 285 (string) wc_clean( $order->get_shipping_address_1() ) 286 ); 287 } 288 289 return new self( 290 (string) wc_clean( $order->get_billing_country() ), 291 (string) wc_clean( $order->get_billing_state() ), 292 (string) wc_clean( $order->get_billing_postcode() ), 293 (string) wc_clean( $order->get_billing_city() ), 294 (string) wc_clean( $order->get_billing_address_1() ) 295 ); 296 } 297 298 /** 269 299 * Build from an admin-order `$_POST` payload. 270 300 * -
woocommerce-services/trunk/src/Utils.php
r3350055 r3732427 68 68 */ 69 69 public static function get_sanitized_request_data( string $key, string $default = '' ): string { 70 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This accessor is how the nonce itself is read: every caller is a wp_verify_nonce() call in WC_Connect_Migration_Survey. Verifying a nonce here would be circular; the callers are responsible for it. 70 71 return sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ?? $default ) ); 71 72 } -
woocommerce-services/trunk/vendor/composer/autoload_classmap.php
r3698279 r3732427 94 94 'Automattic\\WCServices\\StoreNotices\\StoreNoticesNotifier' => $baseDir . '/src/StoreNotices/StoreNoticesNotifier.php', 95 95 'Automattic\\WCServices\\Tax\\Address' => $baseDir . '/src/Tax/Address.php', 96 'Automattic\\WCServices\\Tax\\StoreAddressNotice' => $baseDir . '/src/Tax/StoreAddressNotice.php', 97 'Automattic\\WCServices\\Tax\\StoreAddressVerifier' => $baseDir . '/src/Tax/StoreAddressVerifier.php', 96 98 'Automattic\\WCServices\\Utils' => $baseDir . '/src/Utils.php', 97 99 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', -
woocommerce-services/trunk/vendor/composer/autoload_static.php
r3707890 r3732427 119 119 'Automattic\\WCServices\\StoreNotices\\StoreNoticesNotifier' => __DIR__ . '/../..' . '/src/StoreNotices/StoreNoticesNotifier.php', 120 120 'Automattic\\WCServices\\Tax\\Address' => __DIR__ . '/../..' . '/src/Tax/Address.php', 121 'Automattic\\WCServices\\Tax\\StoreAddressNotice' => __DIR__ . '/../..' . '/src/Tax/StoreAddressNotice.php', 122 'Automattic\\WCServices\\Tax\\StoreAddressVerifier' => __DIR__ . '/../..' . '/src/Tax/StoreAddressVerifier.php', 121 123 'Automattic\\WCServices\\Utils' => __DIR__ . '/../..' . '/src/Utils.php', 122 124 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', -
woocommerce-services/trunk/vendor/composer/installed.php
r3707890 r3732427 4 4 'pretty_version' => 'dev-trunk', 5 5 'version' => 'dev-trunk', 6 'reference' => ' 52b37612e215ad51a541900cf94eca91c8691fee',6 'reference' => 'd726c3d7abe4c8fa65a77d8cde0a81087380213a', 7 7 'type' => 'library', 8 8 'install_path' => __DIR__ . '/../../', … … 14 14 'pretty_version' => 'dev-trunk', 15 15 'version' => 'dev-trunk', 16 'reference' => ' 52b37612e215ad51a541900cf94eca91c8691fee',16 'reference' => 'd726c3d7abe4c8fa65a77d8cde0a81087380213a', 17 17 'type' => 'library', 18 18 'install_path' => __DIR__ . '/../../', -
woocommerce-services/trunk/vendor/composer/jetpack_autoload_classmap.php
r3698279 r3732427 367 367 'path' => $baseDir . '/src/Tax/Address.php' 368 368 ), 369 'Automattic\\WCServices\\Tax\\StoreAddressNotice' => array( 370 'version' => 'dev-trunk', 371 'path' => $baseDir . '/src/Tax/StoreAddressNotice.php' 372 ), 373 'Automattic\\WCServices\\Tax\\StoreAddressVerifier' => array( 374 'version' => 'dev-trunk', 375 'path' => $baseDir . '/src/Tax/StoreAddressVerifier.php' 376 ), 369 377 'Automattic\\WCServices\\Utils' => array( 370 378 'version' => 'dev-trunk', -
woocommerce-services/trunk/woocommerce-services.php
r3707890 r3732427 9 9 * Text Domain: woocommerce-services 10 10 * Domain Path: /i18n/languages/ 11 * Version: 3.7.0 11 * License: GPLv2 or later 12 * License URI: https://www.gnu.org/licenses/gpl-2.0.html 13 * Version: 3.7.1 12 14 * Requires Plugins: woocommerce 13 15 * Requires PHP: 7.4 … … 15 17 * Tested up to: 7.1 16 18 * WC requires at least: 10.9 17 * WC tested up to: 11. 119 * WC tested up to: 11.2 18 20 * 19 21 * Copyright (c) 2017-2023 Automattic … … 94 96 */ 95 97 const DISMISS_SERVER_NOTICE_NONCE_NAME = '_wc_connect_notice_nonce'; 98 99 /** 100 * Most bytes of a request body log_rest_api_errors() writes to the log. 101 * 102 * @var int 103 */ 104 private const REST_ERROR_LOG_BODY_LIMIT = 1024; 96 105 97 106 /** … … 1385 1394 * Note: intended to be hooked into 'rest_request_before_callbacks' 1386 1395 * 1387 * @param WP_HTTP_Response $response Result to send to the client. Usually a WP_REST_Response. 1388 * @param WP_REST_Server $handler ResponseHandler instance (usually WP_REST_Server). 1389 * @param WP_REST_Request $request Request used to generate the response. 1396 * WordPress applies that filter before the route's permission check, and skips the 1397 * check when the request already failed (invalid JSON, missing or invalid params). 1398 * So only requests the route itself allows are logged. The error line is always 1399 * written; the request body only when debug logging is on, shortened and escaped. 1400 * The admin error notice is not touched. 1401 * 1402 * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client. Usually a WP_REST_Response. 1403 * @param array $handler Route handler used for the request. 1404 * @param WP_REST_Request $request Request used to generate the response. 1390 1405 * 1391 1406 * @return mixed - pass through value of $response. 1392 1407 */ 1393 1408 public function log_rest_api_errors( $response, $handler, $request ) { 1394 if ( ! is_wp_error( $response ) ) {1409 if ( ! is_wp_error( $response ) || ! $request instanceof WP_REST_Request ) { 1395 1410 return $response; 1396 1411 } 1397 1412 1398 if ( 0 === strpos( $request->get_route(), '/wc/v1/connect/' ) ) { 1399 $route_info = $request->get_method() . ' ' . $request->get_route(); 1400 1401 $this->get_logger()->error( $response, $route_info ); 1402 $this->get_logger()->error( $route_info, $request->get_body() ); 1413 if ( 0 !== strpos( $request->get_route(), '/wc/v1/connect/' ) || ! $this->rest_route_allows_request( $handler, $request ) ) { 1414 return $response; 1415 } 1416 1417 $logger = $this->get_logger(); 1418 $route_info = $request->get_method() . ' ' . $this->encode_for_log( $request->get_route() ); 1419 1420 // log() rather than error(): a REST error must not replace the admin error notice. 1421 $logger->log( $response, $route_info, true ); 1422 1423 // A request with no body has nothing to add. 1424 if ( $logger->is_logging_enabled() && '' !== (string) $request->get_body() ) { 1425 $logger->log( $route_info, $this->format_rest_body_for_log( $request->get_body() ) ); 1403 1426 } 1404 1427 1405 1428 return $response; 1429 } 1430 1431 /** 1432 * Whether the route's own permission check allows a request. 1433 * 1434 * WordPress skips its own check for a request that failed validation. It still 1435 * calls the callback afterwards to build the Allow header, which does not decide 1436 * access. A missing or non-callable check counts as denied. 1437 * 1438 * @param array|mixed $handler Route handler used for the request. 1439 * @param WP_REST_Request $request Request used to generate the response. 1440 * 1441 * @return bool 1442 */ 1443 private function rest_route_allows_request( $handler, $request ) { 1444 if ( ! is_array( $handler ) || empty( $handler['permission_callback'] ) || ! is_callable( $handler['permission_callback'] ) ) { 1445 return false; 1446 } 1447 1448 $permission = call_user_func( $handler['permission_callback'], $request ); 1449 1450 return ! is_wp_error( $permission ) && false !== $permission && null !== $permission; 1451 } 1452 1453 /** 1454 * JSON-encode request text for the log. 1455 * 1456 * ASCII line breaks and control characters are escaped, so the text cannot add lines 1457 * of its own to the log. Invalid UTF-8 is replaced rather than dropping the text. 1458 * 1459 * @param string $text Text taken from the request. 1460 * 1461 * @return string The quoted, escaped text. 1462 */ 1463 private function encode_for_log( $text ) { 1464 $encoded = wp_json_encode( (string) $text, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE ); 1465 1466 return false === $encoded ? '""' : $encoded; 1467 } 1468 1469 /** 1470 * Describe a request body for the log: its length and an encoded excerpt. 1471 * 1472 * @param string $body Raw request body. 1473 * 1474 * @return string 1475 */ 1476 private function format_rest_body_for_log( $body ) { 1477 $body = (string) $body; 1478 $length = strlen( $body ); 1479 $is_long = $length > self::REST_ERROR_LOG_BODY_LIMIT; 1480 $excerpt = $body; 1481 1482 if ( $is_long ) { 1483 $excerpt = function_exists( 'mb_strcut' ) 1484 ? mb_strcut( $body, 0, self::REST_ERROR_LOG_BODY_LIMIT, 'UTF-8' ) 1485 : substr( $body, 0, self::REST_ERROR_LOG_BODY_LIMIT ); 1486 } 1487 1488 return sprintf( 'body, %d bytes: %s%s', $length, $this->encode_for_log( $excerpt ), $is_long ? '...' : '' ); 1406 1489 } 1407 1490 … … 1783 1866 1784 1867 // Order list page doesn't have the action parameter in the querystring. 1868 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: distinguishes WooCommerce's order list screen from the single order screen, which is the only one that carries the action arg. It only gates which assets are enqueued. 1785 1869 if ( isset( $_GET['action'] ) ) { 1786 1870 return false; … … 1839 1923 } 1840 1924 1841 wp_register_script( 'wc_services_admin_pointers', $this->wc_connect_base_url . 'woocommerce-services-admin-pointers-' . $plugin_version . '.js', array( 'wp-pointer', 'jquery' ), null ); 1842 wp_register_style( 'wc_connect_banner', $this->wc_connect_base_url . 'woocommerce-services-banner-' . $plugin_version . '.css', array(), null ); 1843 wp_register_script( 'wc_connect_banner', $this->wc_connect_base_url . 'woocommerce-services-banner-' . $plugin_version . '.js', array(), null ); 1925 wp_register_script( 'wc_services_admin_pointers', $this->wc_connect_base_url . 'woocommerce-services-admin-pointers-' . $plugin_version . '.js', array( 'wp-pointer', 'jquery' ), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, WordPress.WP.EnqueuedResourceParameters.NotInFooter -- Served from the Connect Server and the version is already in the file name, so no ?ver= is wanted. Registered in the head because the wp-pointer it drives runs before the footer. 1926 wp_register_style( 'wc_connect_banner', $this->wc_connect_base_url . 'woocommerce-services-banner-' . $plugin_version . '.css', array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Served from the Connect Server and the version is already in the file name, so no ?ver= is wanted. 1927 wp_register_script( 'wc_connect_banner', $this->wc_connect_base_url . 'woocommerce-services-banner-' . $plugin_version . '.js', array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, WordPress.WP.EnqueuedResourceParameters.NotInFooter -- Served from the Connect Server and the version is already in the file name, so no ?ver= is wanted. Registered in the head so the banner is styled and wired before first paint. 1844 1928 1845 1929 $i18n_json = $this->get_i18n_json(); … … 1869 1953 1870 1954 foreach ( $shipping_service_ids as $shipping_service_id ) { 1955 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Asks only whether an enabled zone method row exists for this method id; WC_Shipping_Zones has no such reader and would instantiate every shipping method to answer it. Not cached because zone methods are enabled and disabled from WooCommerce's own screens with no hook this plugin could invalidate on, and a stale answer would misreport the store's active services to the Connect server. 1871 1956 $is_active = $wpdb->get_var( 1872 1957 $wpdb->prepare( … … 2071 2156 $meta_table_to_check = OrderUtil::get_table_for_order_meta(); 2072 2157 2158 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Indexed EXISTS probe on the order meta table; core offers no "does any order carry this meta key" API, and wc_get_orders() cannot run this early (the check is reachable from the all_plugins filter). Left uncached deliberately: the answer decides whether the plugin's shipping features load at all, and the meta is also written by the WooCommerce Shipping migration, so there is no write this plugin could hang invalidation on, and serving a stale "no labels" would hide shipping from a store that has it. 2073 2159 return (bool) $wpdb->get_var( 2074 2160 $wpdb->prepare( … … 2168 2254 'https://cdn.sift.com/s.js', 2169 2255 array(), 2170 null, 2256 null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Third-party script served from Sift's CDN, which versions it server side; a ?ver= we invent would be wrong. 2171 2257 array( 2172 2258 'strategy' => 'defer', … … 2252 2338 $encoded_arguments = wp_json_encode( $extra_args ); 2253 2339 ?> 2254 <div class="wcc-root woocommerce <?php echo esc_attr( $root_view ); ?>" data-args="<?php echo wc_esc_json( $encoded_arguments ); ?>">2340 <div class="wcc-root woocommerce <?php echo esc_attr( $root_view ); ?>" data-args="<?php echo wc_esc_json( $encoded_arguments ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wc_esc_json() escapes JSON for use in an HTML attribute. ?>"> 2255 2341 <span class="form-troubles" style="opacity: 0"> 2256 <?php printf( esc_html__( 'Section not loading? Visit the <a href="%s">status page</a> for troubleshooting steps.', 'woocommerce-services' ), esc_url( $debug_page_uri ) ); ?> 2342 <?php 2343 /* translators: %s: URL of the status page */ 2344 printf( esc_html__( 'Section not loading? Visit the <a href="%s">status page</a> for troubleshooting steps.', 'woocommerce-services' ), esc_url( $debug_page_uri ) ); 2345 ?> 2257 2346 </span> 2258 2347 </div> … … 2389 2478 sprintf( 2390 2479 /* translators: %s: documentation URL */ 2391 __( $banner->message, 'woocommerce-services' ), 2480 __( $banner->message, 'woocommerce-services' ), // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- The message is supplied by the WooCommerce Tax server. 2392 2481 'https://woocommerce.com/document/woocommerce-shipping-and-tax/woocommerce-shipping/#how-do-i-migrate-from-wcst' 2393 2482 ) . … … 2407 2496 $plugin_version = self::get_wcs_version(); 2408 2497 wp_register_style( 'wcst_wcshipping_migration_admin_notice', $this->wc_connect_base_url . 'woocommerce-services-wcshipping-migration-admin-notice-' . $plugin_version . '.css', array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion 2409 wp_register_script( 'wcst_wcshipping_migration_admin_notice', $this->wc_connect_base_url . 'woocommerce-services-wcshipping-migration-admin-notice-' . $plugin_version . '.js', array(), null ); 2498 wp_register_script( 'wcst_wcshipping_migration_admin_notice', $this->wc_connect_base_url . 'woocommerce-services-wcshipping-migration-admin-notice-' . $plugin_version . '.js', array(), null ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion, WordPress.WP.EnqueuedResourceParameters.NotInFooter -- Served from the Connect Server and the version is already in the file name, so no ?ver= is wanted. Registered in the head alongside the stylesheet above, which carries the same note. 2410 2499 wp_localize_script( 2411 2500 'wcst_wcshipping_migration_admin_notice',
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)