Plugin Directory

Changeset 3732427


Ignore:
Timestamp:
10/07/2026 11:09:45 AM (3 weeks ago)
Author:
SamNajian
Message:

Tagging version 3.7.1

Location:
woocommerce-services
Files:
24 added
18 deleted
102 edited
1 copied

Legend:

Unmodified
Added
Removed
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-api-client-live.php

    r3598126 r3732427  
    7171                                                'wcc_server_error',
    7272                                                sprintf(
     73                                                        /* translators: %d: HTTP response code */
    7374                                                        __( 'Error: The WooCommerce Tax server returned HTTP code: %d', 'woocommerce-services' ),
    7475                                                        $response_code
    … …  
    9293                                                'wcc_server_empty_response',
    9394                                                sprintf(
     95                                                        /* translators: %d: HTTP response code */
    9496                                                        __( 'Error: The WooCommerce Tax server returned ( %d ) and an empty response body.', 'woocommerce-services' ),
    9597                                                        $response_code
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-api-client.php

    r3452871 r3732427  
    9797                                                        'product_missing_weight',
    9898                                                        sprintf(
     99                                                                /* translators: %d: product ID */
    99100                                                                __( 'Product ( ID: %d ) did not include a weight. Shipping rates cannot be calculated.', 'woocommerce-services' ),
    100101                                                                $product->get_id()
    … …  
    112113                                                        'product_missing_dimension',
    113114                                                        sprintf(
     115                                                                /* translators: %d: product ID */
    114116                                                                __( 'Product ( ID: %d ) is missing a dimension value. Shipping rates cannot be calculated.', 'woocommerce-services' ),
    115117                                                                $product->get_id()
    … …  
    616618
    617619                        $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.
    620622                                'method'      => $method,
    621623                        );
    … …  
    670672                                sprintf(
    671673                                        '%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 )
    680682                                )
    681683                        );
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-custom-surcharge.php

    r3417327 r3732427  
    112112                                array(
    113113                                        'value' => 0.28,
    114                                         'text'  => __( 'Retail Delivery Fee', 'woocommerce_services' ),
     114                                        'text'  => __( 'Retail Delivery Fee', 'woocommerce-services' ),
    115115                                ),
    116116                                $cart
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-debug-tools.php

    r3289651 r3732427  
    9090                        global $wpdb;
    9191
     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.
    9293                        $found_ca_rates = $wpdb->get_results(
    9394                                $wpdb->prepare(
    … …  
    124125
    125126                        echo '<div class="updated inline"><p>';
     127                        /* translators: %1$d: number of deleted rows */
    126128                        printf( esc_html__( 'Successfully deleted %1$d rows from the database.', 'woocommerce-services' ), intval( $deleted_count ) );
    127129                        echo '</p></div>';
    … …  
    137139
    138140                        $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.
    139142                                $wpdb->query(
    140143                                        "DELETE FROM {$wpdb->options} WHERE option_name LIKE '%tj\_tax\_%';"
    … …  
    143146
    144147                        echo '<div class="updated inline"><p>';
     148                        /* translators: %1$d: number of deleted transients */
    145149                        printf( esc_html__( 'Successfully deleted %1$d transients from the database.', 'woocommerce-services' ), intval( $deleted_count ) );
    146150                        echo '</p></div>';
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-error-notice.php

    r3698279 r3732427  
    7878
    7979                private function notice_enabled() {
    80                          return WC_Connect_Options::get_option( 'error_notice', false );
     80                        return WC_Connect_Options::get_option( 'error_notice', false );
    8181                }
    8282
    … …  
    119119                                $product_id   = $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
    120120                                $message      = sprintf(
     121                                        /* translators: %1$s: product edit URL, %2$s: product name */
    121122                                        __( '<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' ),
    122123                                        get_edit_post_link( $product_id ),
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-extension-compatibility.php

    r3310537 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Extension_Compatibility' ) ) {
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-functions.php

    r3685245 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Functions' ) ) {
    … …  
    1923                                ( defined( 'REST_REQUEST' ) || defined( 'REST_API_REQUEST' ) || defined( 'XMLRPC_REQUEST' ) ) ||
    2024                                // 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.
    2126                                ( isset( $_GET['rest_route'] ) && isset( $_GET['_for'] ) && ( 'jetpack' === $_GET['_for'] ) )
    2227                        );
    … …  
    153158
    154159                        // 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.
    155161                        $rates = $wpdb->get_results(
    156162                                $wpdb->prepare(
    … …  
    170176                        $rows = array(
    171177                                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' ),
    182188                                ),
    183189                        );
    184190
    185191                        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.
    186193                                $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 ) );
    188196
    189197                                $rows[] = array(
    … …  
    338346                        foreach ( $files as $file ) {
    339347                                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_fopen
     348                                        $file_handle = @fopen( trailingslashit( $file['base'] ) . $file['file'], 'wb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen
    341349                                        if ( $file_handle ) {
    342                                                 fwrite( $file_handle, $file['content'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fwrite
    343                                                 fclose( $file_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose
     350                                                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
    344352                                        }
    345353                                }
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-help-view.php

    r3505023 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Help_View' ) ) {
    … …  
    5963                                        'state'   => 'error',
    6064                                        'message' => sprintf(
     65                                                /* translators: %1$s: minimum required WooCommerce version, %2$s: installed WooCommerce version */
    6166                                                __( 'WooCommerce %1$s or higher is required (You are running %2$s)', 'woocommerce-services' ),
    6267                                                WOOCOMMERCE_CONNECT_MINIMUM_WOOCOMMERCE_VERSION,
    … …  
    7378                                        'state'   => 'success',
    7479                                        'message' => sprintf(
     80                                                /* translators: %s: installed WooCommerce version */
    7581                                                __( 'WooCommerce %s is configured correctly', 'woocommerce-services' ),
    7682                                                WC()->version
    … …  
    166172
    167173                                $subtitle = sprintf(
     174                                        /* translators: %s: shipping zone name */
    168175                                        __( '%s Shipping Zone', 'woocommerce-services' ),
    169176                                        $enabled_service->zone_name
    … …  
    329336                                        'state'              => 'error',
    330337                                        'settings_link_type' => '',
     338                                        /* translators: %s: store country code */
    331339                                        'message'            => sprintf( __( 'Your store\'s country (%s) is not supported. Automated taxes functionality is disabled', 'woocommerce-services' ), $store_country ),
    332340                                );
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-jetpack.php

    r3685245 r3732427  
    55use Automattic\Jetpack\Status;
    66use Automattic\Jetpack\Status\Host;
     7
     8if ( ! defined( 'ABSPATH' ) ) {
     9        exit;
     10}
    711
    812if ( ! class_exists( 'WC_Connect_Jetpack' ) ) {
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-label-reports.php

    r3204770 r3732427  
    22
    33use Automattic\WooCommerce\Utilities\OrderUtil;
     4
     5if ( ! defined( 'ABSPATH' ) ) {
     6        exit;
     7}
    48
    59if ( ! class_exists( 'WC_Connect_Label_Reports' ) ) {
    … …  
    1923
    2024                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';
    2227                        ?>
    2328                        <a
    … …  
    4146                        $table_name = OrderUtil::get_table_for_order_meta();
    4247                        $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.
    4349                        $db_results = $wpdb->get_results(
    4450                                $wpdb->prepare(
    … …  
    129135                        );
    130136
    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';
    132139
    133140                        if ( ! in_array( $current_range, array( 'custom', 'year', 'last_month', 'month', '7day' ) ) ) {
    … …  
    198205                                                        <tr>
    199206                                                                <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 ) ) ) ); ?>
    201208                                                                </th>
    202209                                                                <td>
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-logger.php

    r3452871 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Logger' ) ) {
    … …  
    105109                 *
    106110                 * @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)
    108112                 */
    109113                public function log( $message, $context = '', $force = false ) {
    … …  
    111115
    112116                        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.
    114118                                error_log( $log_message );
    115119                        }
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-migration-survey.php

    r3350055 r3732427  
    88
    99use Automattic\WCServices\Utils;
     10
     11if ( ! defined( 'ABSPATH' ) ) {
     12        exit;
     13}
    1014
    1115if ( ! class_exists( 'WC_Connect_Migration_Survey' ) ) {
    … …  
    4246                public function should_show_survey() {
    4347                        // 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.
    4449                        if ( isset( $_GET['force_survey'] ) ) {
    4550                                return true;
    … …  
    110115
    111116                        // 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;
    113119
    114120                        if ( ! $survey_data ) {
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-nux.php

    r3685245 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Nux' ) ) {
    … …  
    1822                 */
    1923                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';
    2034
    2135                /**
    … …  
    136150                                global $wpdb;
    137151
     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.
    138153                                $results     = $wpdb->get_results(
    139154                                        $wpdb->prepare(
    … …  
    175190                                                '<h3>%s</h3><p>%s</p>',
    176191                                                __( 'Discounted Shipping Labels', 'woocommerce-services' ),
     192                                                /* translators: %s: list of supported carriers */
    177193                                                sprintf( __( "When you're ready, purchase and print discounted labels from %s right here.", 'woocommerce-services' ), implode( ' or ', $supported_carriers ) )
    178194                                        ),
    … …  
    300316
    301317                /**
     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                /**
    302333                 * Whether the WooCommerce Tax connection banner may render on the given screen.
    303334                 *
    … …  
    320351
    321352                        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.
    322354                                && isset( $_GET['tab'] ) && 'tax' === $_GET['tab'];
    323355                }
    … …  
    385417                        // If this is the case, the admin can connect the site on their own, and should be able to use WCS as ususal
    386418                        $jetpack_install_status = $this->get_jetpack_install_status();
     419                        $can_accept_tos         = self::current_user_can_accept_tos();
    387420
    388421                        $banner_to_display = self::get_banner_type_to_display(
    … …  
    390423                                        'jetpack_connection_status'       => $jetpack_install_status,
    391424                                        '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 ),
    394428                                        'is_site_only_connection'         => WC_Connect_Jetpack::is_site_only_connection(),
    395429                                )
    … …  
    518552                }
    519553
     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
    520572                public function show_banner_after_connection() {
    521573                        if ( ! $this->should_display_nux_notice_for_current_store_locale() ) {
    … …  
    528580
    529581                        // 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 ) ) {
    531585                                // No longer need to keep track of whether the before connection banner was displayed.
    532586                                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' ) ) );
    534588                                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;
    535597                        }
    536598
    … …  
    550612                                        'description'       => esc_html( sprintf( $description_base, $feature_list ) ),
    551613                                        '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
    556621                                        ),
    557622                                        'image_url'         => plugins_url(
    … …  
    573638                        }
    574639
    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 ) ) {
    576651                                WC_Connect_Options::update_option( 'tos_accepted', true );
    577652
    578653                                $this->tracks->opted_in( 'tos_banner' );
    579654
    580                                 wp_safe_redirect( remove_query_arg( 'wcs-nux-tos' ) );
     655                                wp_safe_redirect( remove_query_arg( array( 'wcs-nux-tos', '_wpnonce' ) ) );
    581656                                exit;
    582657                        }
    … …  
    592667                                        'description'       => esc_html( sprintf( $description_base, $feature_list ) ),
    593668                                        '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
    598676                                        ),
    599677                                        'image_url'         => plugins_url(
    … …  
    653731                                                <p class="wcs-nux__notice-content-tos">
    654732                                                        <?php
    655                                                         /* translators: %1$s example values include "Install Jetpack and CONNECT >", "Activate Jetpack and CONNECT >", "CONNECT >" */
    656733                                                        printf(
    657734                                                                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 */
    658736                                                                        __( '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' ),
    659737                                                                        array(
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-options.php

    r3452871 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Options' ) ) {
    … …  
    138142                        $names  = (array) $names;
    139143                        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 );
    141145                                return false;
    142146                        }
    … …  
    332336                private static function delete_all_shipping_methods_options() {
    333337                        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.
    334339                        $methods = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zone_methods " );
    335340
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-order-presenter.php

    r2818899 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Order_Presenter' ) ) {
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-payment-gateway.php

    r1586050 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Payment_Gateway' ) ) {
    … …  
    1216
    1317                        $this->init_settings();
    14 
    1518                }
    16 
    1719        }
    1820
    1921}
    20 
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-payment-methods-store.php

    r3097468 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Payment_Methods_Store' ) ) {
    … …  
    2024                protected $logger;
    2125
    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                ) {
    2431
    2532                        $this->service_settings_store = $service_settings_store;
    2633                        $this->api_client             = $api_client;
    2734                        $this->logger                 = $logger;
    28 
    2935                }
    3036
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-paypal-ec.php

    r3289651 r3732427  
    135135                                                sprintf(
    136136                                                        wp_kses(
     137                                                                /* translators: %s: PayPal signup URL */
    137138                                                                __( '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' ),
    138139                                                                array( 'a' => array( 'href' => array() ) )
    … …  
    194195                                || ( // WooCommerce » Settings » Payments.
    195196                                        '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.
    196198                                        && isset( $_GET['tab'] ) && 'checkout' === $_GET['tab']
    197199                                        )
    198200                                || ( // WooCommerce » Extensions » Payments.
    199201                                        '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.
    200203                                        && isset( $_GET['section'] ) && 'payment-gateways' === $_GET['section']
    201204                                        )
    … …  
    267270
    268271                                // 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 */
    270274                                $form_fields['paymentaction']['description'] = sprintf( __( '%s (Note that "authorizing payment only" requires linking a PayPal account.)', 'woocommerce-services' ), $form_fields['paymentaction']['description'] );
    271275
    272276                                // Communicate WCS proxying and provide option to disable
    273                                 $reset_link         = add_query_arg(
     277                                $reset_link = add_query_arg(
    274278                                        array(
    275279                                                'reroute_requests' => 'no',
    … …  
    278282                                        wc_gateway_ppec()->get_admin_setting_link()
    279283                                );
     284                                /* translators: %s: URL that resets the PayPal API credentials */
    280285                                $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' );
    281286                                if ( empty( $settings->api_username ) ) {
    … …  
    291296                        } else {
    292297                                // Provide option to enable request proxying
    293                                 $reset_link         = add_query_arg(
     298                                $reset_link = add_query_arg(
    294299                                        array(
    295300                                                'reroute_requests' => 'yes',
    … …  
    298303                                        wc_gateway_ppec()->get_admin_setting_link()
    299304                                );
     305                                /* translators: %s: URL that resets the PayPal API credentials */
    300306                                $api_creds_template = __( 'To authenticate payments with WooCommerce Tax, <a href="%s">click here</a>.', 'woocommerce-services' );
    301307                                if ( empty( $settings->api_username ) ) {
    … …  
    339345                                empty( $_GET['reroute_requests'] ) ||
    340346                                empty( $_GET['nonce'] ) ||
    341                                 ! wp_verify_nonce( $_GET['nonce'], 'reroute_requests' ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
     347                                ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['nonce'] ) ), 'reroute_requests' )
    342348                        ) {
    343349                                return;
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-privacy.php

    r3289651 r3732427  
    3838                        sprintf(
    3939                                wp_kses(
     40                                        /* translators: %s: URL of the privacy documentation */
    4041                                        __( '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' ),
    4142                                        array(
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-service-schemas-store.php

    r3289651 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Service_Schemas_Store' ) ) {
    … …  
    209213                public function get_service_schema_by_instance_id( $instance_id ) {
    210214                        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.
    211216                        $method_id = $wpdb->get_var(
    212217                                $wpdb->prepare(
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-service-schemas-validator.php

    r2498257 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Service_Schemas_Validator' ) ) {
    … …  
    4549                                }
    4650
    47                                 $service_counter ++;
     51                                ++$service_counter;
    4852                        }
    4953
    … …  
    190194                        return true;
    191195                }
    192 
    193196        }
    194197
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-service-settings-store.php

    r3327549 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Service_Settings_Store' ) ) {
    … …  
    262266                 * Updates the existing label data
    263267                 *
    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.
    268273                 */
    269274                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
    272283                        $labels_data = $this->get_label_order_meta_data( $order_id );
     284                        $matched     = false;
    273285                        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;
    275288                                        $result                = array_merge( $label_data, (array) $new_label_data );
    276289                                        $labels_data[ $index ] = $result;
    … …  
    282295                                }
    283296                        }
     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
    284303                        $order->update_meta_data( 'wc_connect_labels', $labels_data );
    285304                        $order->save();
    … …  
    291310                 *
    292311                 * @param $order_id
    293                  * @param array    $new_labels - labels to be added
     312                 * @param array $new_labels - labels to be added
    294313                 */
    295314                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
    296322                        $labels_data = $this->get_label_order_meta_data( $order_id );
    297323                        $labels_data = array_merge( $new_labels, $labels_data );
    298                         $order       = wc_get_order( $order_id );
    299324                        $order->update_meta_data( 'wc_connect_labels', $labels_data );
    300325                        $order->save();
    … …  
    306331
    307332                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
    309340                        $wc_address = $order->get_address( 'shipping' );
    310341
    … …  
    361392
    362393                public function get_enabled_services_by_ids( $service_ids ) {
    363                         if ( empty( $service_ids ) ) {
     394                        if ( empty( $service_ids ) || ! is_array( $service_ids ) ) {
    364395                                return array();
    365396                        }
    … …  
    367398                        $enabled_services = array();
    368399
    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' ) );
    379404
    380405                        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.
    382408                        $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                                )
    388417                        );
    389                         // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     418                        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
    390419
    391420                        if ( empty( $methods ) ) {
    … …  
    445474                                $new_method_id = $service_schema->method_id;
    446475
     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.
    447477                                $wpdb->update(
    448478                                        "{$wpdb->prefix}woocommerce_shipping_zone_methods",
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-settings-pages.php

    r3310537 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Settings_Pages' ) ) {
    … …  
    115119                        }
    116120
     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.
    117122                        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'] ) );
    119125                                $extra_args['order_id']   = $from_order;
    120126                                $extra_args['order_href'] = get_edit_post_link( $from_order );
    121127                        }
    122128
     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.
    123130                        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'] ) );
    125133                                $extra_args['carrier']    = $carrier;
    126134                                $extra_args['continents'] = $this->continents->get();
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-shipping-label.php

    r3350055 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Shipping_Label' ) ) {
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-shipping-method.php

    r3327549 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Shipping_Method' ) ) {
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-taxjar-integration.php

    r3679638 r3732427  
    33use Automattic\WCServices\StoreNotices\StoreNoticesNotifier;
    44use Automattic\WCServices\Tax\Address;
     5use Automattic\WCServices\Tax\StoreAddressNotice;
     6use Automattic\WCServices\Tax\StoreAddressVerifier;
    57
    68class WC_Connect_TaxJar_Integration {
    … …  
    1719
    1820        /**
    19          * @var StoreNoticesNotifier
     21         * Shows customer-input errors at checkout. Optional: null when none is passed to the constructor.
     22         *
     23         * @var StoreNoticesNotifier|null
    2024         */
    2125        private $notifier;
    … …  
    6872
    6973        /**
     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        /**
    7084         * Tax snapshots captured before an out-of-cart recalculation, keyed by order id,
    7185         * so they can be restored after WC recalculates the order totals.
    … …  
    7488         */
    7589        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        );
    76139
    77140        /**
    … …  
    108171        const OPTION_NAME              = 'wc_connect_taxes_enabled';
    109172        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        );
    110194
    111195        /**
    … …  
    182266        }
    183267
     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
    184295        public function init() {
    185296                // Only enable WCS TaxJar integration if the official TaxJar plugin isn't active.
    … …  
    226337                $this->configure_tax_settings();
    227338
     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
    228344                // Calculate Taxes at Cart / Checkout
    229345                if ( class_exists( 'WC_Cart_Totals' ) ) { // Woo 3.2+
    … …  
    238354                // Preserve recorded taxes when an existing order is recalculated outside the
    239355                // 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.
    241359                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 );
    242367
    243368                // Set customer taxable location for local pickup
    … …  
    284409                $backedup_tax_rates_url = admin_url( '/admin.php?page=wc-status&tab=connect#tax-rate-backups' );
    285410
     411                /* translators: %1$s: opening link tag, %2$s: closing link tag */
    286412                $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>';
    287413
     414                /* translators: %1$s: opening link tag, %2$s: closing link tag */
    288415                $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>' : '';
    289416
    290417                $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 */
    291419                $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>';
    292420
    … …  
    357485        public function fix_tooltip_keepalive() {
    358486                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.
    359488                if ( 'admin.php' !== $pagenow || ! isset( $_GET['page'] ) || 'wc-settings' !== $_GET['page'] || ! isset( $_GET['tab'] ) || 'tax' !== $_GET['tab'] || ! empty( $_GET['section'] ) ) {
    360489                        return;
    … …  
    509638                $formatted_message = is_scalar( $message ) ? $message : json_encode( $message );
    510639
    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.
    512643                $state_zip_mismatch = false !== strpos( $formatted_message, 'to_zip' ) && false !== strpos( $formatted_message, 'is not used within to_state' );
    513644                $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
    516656                        $fields              = WC()->countries->get_address_fields();
    517657                        $postcode_field_name = __( 'ZIP/Postal code', 'woocommerce-services' );
    … …  
    521661
    522662                        if ( $state_zip_mismatch ) {
     663                                /* translators: %s: ZIP/Postal code checkout field label */
    523664                                $message = sprintf( _x( '%s does not match the selected state.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name );
    524665                        } elseif ( $malformed_postcode ) {
     666                                /* translators: %s: ZIP/Postal code checkout field label */
    525667                                $message = sprintf( _x( '%s is not formatted correctly.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name );
    526668                        } else {
     669                                /* translators: %s: ZIP/Postal code checkout field label */
    527670                                $message = sprintf( _x( 'Invalid %s entered.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name );
    528671                        }
    … …  
    653796         * Calculate tax / totals using TaxJar for backend orders
    654797         *
    655          * Unchanged from the TaxJar plugin.
     798         * Based on the TaxJar plugin.
    656799         * See: https://github.com/taxjar/taxjar-woocommerce-plugin/blob/96b5d57/includes/class-wc-taxjar-integration.php#L557
    657800         *
    … …  
    660803        public function calculate_backend_totals( $order_id ) {
    661804                $order      = wc_get_order( $order_id );
    662                 $address    = $this->get_backend_address();
     805                $address    = $this->fill_missing_backend_street( $this->get_backend_address(), $order );
    663806                $line_items = $this->get_backend_line_items( $order );
    664807                if ( method_exists( $order, 'get_shipping_total' ) ) {
    … …  
    678821                        )
    679822                );
    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;
    694838                                }
    695839                        }
    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                 */
    703848        }
    704849
    … …  
    9691114
    9701115        /**
     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        /**
    9711190         * Get line items at checkout
    9721191         *
    … …  
    9911210                        $line_subtotal = wc_format_decimal( $cart_item['line_subtotal'] );
    9921211                        $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() );
    9991213
    10001214                        if ( 'shipping' !== $product->get_tax_status() && ( ! $product->is_taxable() || 'zero-rate' == sanitize_title( $product->get_tax_class() ) ) ) {
    … …  
    11141328                        }
    11151329                        $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 );
    11211331                        if ( 'taxable' !== $tax_status ) {
    11221332                                $tax_code = '99999';
    … …  
    11261336                                // matching note in get_line_items() for why this is recorded, not derived.
    11271337                                $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';
    11281342                        }
    11291343
    … …  
    14491663         */
    14501664        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
    14511672                // Only act if we have TaxJar-calculated rate IDs from this request.
    14521673                if ( empty( $this->response_rate_ids ) || ! is_array( $this->response_rate_ids ) ) {
    … …  
    14801701                }
    14811702
     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 ) {
    14821740                // Build tax rates array from the stored rate IDs.
    14831741                $tax_rates = array();
    1484                 foreach ( $matching_rate_ids as $rate_id ) {
     1742                foreach ( $rate_ids as $rate_id ) {
    14851743                        $rate_id = absint( $rate_id );
    14861744                        if ( ! $rate_id ) {
    … …  
    17231981
    17241982                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' );
    17261984
    17271985                        return false;
    … …  
    17452003
    17462004                        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' );
    17512008
    17522009                                return false;
    … …  
    17842041                        $errors = str_replace( '[postcode]', '[zip]', $errors );
    17852042
    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' );
    17872044
    17882045                        return false;
    … …  
    18282085                        ( empty( $destination->postcode() ) && ! in_array( $destination->country(), WC()->countries->get_vat_countries(), true ) ) ||
    18292086                        ( 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() )
    18312089                ) {
    18322090                        $this->_log( 'Destination address or cart data is incomplete, or the customer is VAT exempt. Aborting.' );
    … …  
    20452303                        );
    20462304
     2305                        // Priorities written per tax class, for shadow_unscoped_rates_above_components().
     2306                        $written = array();
     2307
    20472308                        // Add line item tax rates.
    20482309                        foreach ( $taxes['line_items'] as $line_item_key => $line_item ) {
    … …  
    20732334                                                continue;
    20742335                                        }
     2336                                        $rate_name = self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions );
     2337
    20752338                                        $taxes['rate_ids'][ $line_item_key ][] = $this->create_or_update_tax_rate(
    20762339                                                $location,
    … …  
    20792342                                                $taxes['freight_taxable'],
    20802343                                                $priority,
    2081                                                 self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions )
     2344                                                $rate_name
    20822345                                        );
    20832346
     2347                                        $this->record_written_priority( $written, $tax_class, $priority, $rate_name );
    20842348                                        ++$priority;
    20852349                                }
    … …  
    20932357                                        continue;
    20942358                                }
     2359                                $rate_name = self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions );
     2360
    20952361                                $taxes['rate_ids']['shipping'][] = $this->create_or_update_tax_rate(
    20962362                                        $location,
    … …  
    20992365                                        $taxes['freight_taxable'],
    21002366                                        $priority,
    2101                                         self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions )
     2367                                        $rate_name
    21022368                                );
    21032369
     2370                                $this->record_written_priority( $written, '', $priority, $rate_name );
    21042371                                ++$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                                }
    21052378                        }
    21062379                }
    … …  
    21782451                 * it is handed.
    21792452                 */
    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 );
    21942454
    21952455                /**
    … …  
    22382498                }
    22392499
     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
    22402527                if ( ! empty( $wc_rate ) ) {
    22412528                        $this->_log( ':: Tax Rate Found ::' );
    … …  
    22702557
    22712558                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                }
    22722700        }
    22732701
    … …  
    23712799
    23722800                // 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                }
    23742804
    23752805                if ( false === $response ) {
    … …  
    25112941
    25122942                // 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.
    25132944                if ( 'woocommerce_page_wc-orders' === $wc_order_screen_id && ! isset( $_GET['action'] ) ) {
    25142945                        return false;
    … …  
    25262957                }
    25272958                // 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.
    25292960        }
    25302961
    … …  
    25632994
    25642995                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();
    25673064        }
    25683065
    … …  
    25743071         * the checkout address, so when a REST API or programmatic update changes the
    25753072         * 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.
    25833092         *
    25843093         * Restoration happens on woocommerce_order_after_calculate_totals, the only post-
    … …  
    25993108         */
    26003109        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() ] );
    26103115                        return;
    26113116                }
    26123117
    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'] ) ) {
    26153124                        return;
    26163125                }
    26173126
    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'] ) {
    26283145                        return;
    26293146                }
    … …  
    26413158         *
    26423159         * 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 the
     3160         * recalculated order and, if one is present, applies it and drops it from the
    26443161         * pending set. Keyed by order id so a batch that recalculates several orders
    26453162         * restores each from its own snapshot.
    26463163         *
     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         *
    26473176         * @internal Hooked to woocommerce_order_after_calculate_totals.
    26483177         *
    … …  
    26603189                unset( $this->pre_recalculation_tax_snapshots[ $order_id ] );
    26613190
    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                }
    26633353        }
    26643354
    … …  
    27733463                $order->save();
    27743464        }
     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        }
    27754404}
  • woocommerce-services/tags/3.7.1/classes/class-wc-connect-tracks.php

    r3452871 r3732427  
    9494                        }
    9595
    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'] ) ) : '';
    9999                        $data['blog_url']        = get_option( 'siteurl' );
    100100                        $data['blog_id']         = $jetpack_blog_id;
    … …  
    116116                        }
    117117                }
    118 
    119118        }
    120119
  • woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-account-settings-controller.php

    r2498257 r3732427  
    5858                                'save_failed',
    5959                                sprintf(
     60                                        /* translators: %s: error message */
    6061                                        __( 'Unable to update settings. %s', 'woocommerce-services' ),
    6162                                        $result->get_error_message()
  • woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-base-controller.php

    r2556587 r3732427  
    153153        }
    154154
     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        }
    155192}
  • woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-packages-controller.php

    r2498257 r3732427  
    6565                if ( ! empty( $custom_packages ) ) {
    6666                        // Validate that the new custom packages have unique names.
    67                         $map_package_name            = function( $package ) {
     67                        $map_package_name            = function ( $package ) {
    6868                                return $package['name'];
    6969                        };
    … …  
    7575                                $error                   = array(
    7676                                        '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' ),
    7878                                        'data'    => array( 'package_names' => array_values( $duplicate_package_names ) ),
    7979                                );
    … …  
    8989                                $error = array(
    9090                                        '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' ),
    9292                                        'data'    => array( 'package_names' => array_values( $duplicate_package_names ) ),
    9393                                );
    … …  
    116116                                $error = array(
    117117                                        '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' ),
    119119                                        'data'    => array( 'package_names_by_carrier' => $duplicate_package_names_by_carrier ),
    120120                                );
    … …  
    137137                                $error = array(
    138138                                        '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' ),
    140140                                        'data'    => array( 'package_names_by_carrier' => $duplicate_package_names_by_carrier ),
    141141                                );
  • woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-services-controller.php

    r2498257 r3732427  
    9090                                'validation_failed',
    9191                                sprintf(
     92                                        /* translators: %s: validation error message */
    9293                                        __( 'Unable to update service settings. Validation failed. %s', 'woocommerce-services' ),
    9394                                        $validation_result->get_error_message()
    … …  
    101102                return new WP_REST_Response( array( 'success' => true ), 200 );
    102103        }
    103 
    104104}
  • woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-shipping-label-controller.php

    r3298671 r3732427  
    4343
    4444        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.
    4552                $settings             = $request->get_json_params();
    46                 $order_id             = $request['order_id'];
     53                $order_id             = (string) $order->get_id();
    4754                $settings['order_id'] = $order_id;
    4855
    … …  
    147154                                        $product_names[] = $product->get_title();
    148155                                } else {
    149                                         $order           = wc_get_order( $order_id );
    150156                                        $product_names[] = WC_Connect_Utils::get_product_name_from_order( $product_id, $order );
    151157                                }
    … …  
    158164                }
    159165
    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 );
    161167
    162168                return array(
  • woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-shipping-label-refund-controller.php

    r2498257 r3732427  
    1313
    1414        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
    1525                $response = $this->api_client->send_shipping_label_refund_request( $request['label_id'] );
    1626
    … …  
    3848                        'refund'   => $response->refund,
    3949                );
    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 );
    4151
    4252                return array(
  • woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-shipping-label-status-controller.php

    r2510126 r3732427  
    1313
    1414        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
    1520                $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();
    1730                foreach ( $label_ids as $label_id ) {
    1831                        $response = $this->api_client->get_label_status( $label_id );
    … …  
    2740                        }
    2841
    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 );
    3043                        $labels[] = $label;
    3144                }
  • woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-shipping-rates-controller.php

    r2498257 r3732427  
    3434
    3535        /**
     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        /**
    3664         *
    3765         * @param WP_REST_Request $request - See WC_Connect_API_Client::get_label_rates()
    … …  
    3967         */
    4068        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
    4286                $payload['payment_method_id'] = $this->settings_store->get_selected_payment_method_id();
    43                 $order_id                     = $request['order_id'];
     87                $order_id                     = $order->get_id();
    4488
    4589                // This is the earliest point in the printing label flow where we are sure that
    … …  
    5599                        }
    56100                        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
    59115                                        update_post_meta(
    60                                                 $item['product_id'],
     116                                                (int) $product_id,
    61117                                                'wc_connect_customs_info',
    62118                                                array(
  • woocommerce-services/tags/3.7.1/classes/class-wc-rest-connect-tos-controller.php

    r2498257 r3732427  
    4242
    4343        /**
    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
    4549         */
    4650        public function check_permission( $request ) {
    4751                return current_user_can( 'manage_woocommerce' ) &&
    4852                        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();
    5056        }
    5157}
  • woocommerce-services/tags/3.7.1/classes/wc-api-dev/class-wc-rest-dev-data-continents-controller.php

    r3034437 r3732427  
    7676                                        'args'                => array(
    7777                                                'continent' => array(
    78                                                         'description' => __( '2 character continent code.', 'woocommerce' ),
     78                                                        'description' => __( '2 character continent code.', 'woocommerce-services' ),
    7979                                                        'type'        => 'string',
    8080                                                ),
    … …  
    116116                $data = $this->continents->get_continent( strtoupper( $request['location'] ), $request );
    117117                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 ) );
    119119                }
    120120                return $this->prepare_item_for_response( $data, $request );
    … …  
    181181                                'code'      => array(
    182182                                        'type'        => 'string',
    183                                         'description' => __( '2 character continent code.', 'woocommerce' ),
     183                                        'description' => __( '2 character continent code.', 'woocommerce-services' ),
    184184                                        'context'     => array( 'view' ),
    185185                                        'readonly'    => true,
    … …  
    187187                                'name'      => array(
    188188                                        'type'        => 'string',
    189                                         'description' => __( 'Full name of continent.', 'woocommerce' ),
     189                                        'description' => __( 'Full name of continent.', 'woocommerce-services' ),
    190190                                        'context'     => array( 'view' ),
    191191                                        'readonly'    => true,
    … …  
    193193                                'countries' => array(
    194194                                        'type'        => 'array',
    195                                         'description' => __( 'List of countries on this continent.', 'woocommerce' ),
     195                                        'description' => __( 'List of countries on this continent.', 'woocommerce-services' ),
    196196                                        'context'     => array( 'view' ),
    197197                                        'readonly'    => true,
    … …  
    203203                                                        'code'           => array(
    204204                                                                'type'        => 'string',
    205                                                                 'description' => __( 'ISO3166 alpha-2 country code.', 'woocommerce' ),
     205                                                                'description' => __( 'ISO3166 alpha-2 country code.', 'woocommerce-services' ),
    206206                                                                'context'     => array( 'view' ),
    207207                                                                'readonly'    => true,
    … …  
    209209                                                        'currency_code'  => array(
    210210                                                                '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' ),
    212212                                                                'context'     => array( 'view' ),
    213213                                                                'readonly'    => true,
    … …  
    215215                                                        'currency_pos'   => array(
    216216                                                                'type'        => 'string',
    217                                                                 'description' => __( 'Currency symbol position for this country.', 'woocommerce' ),
     217                                                                'description' => __( 'Currency symbol position for this country.', 'woocommerce-services' ),
    218218                                                                'context'     => array( 'view' ),
    219219                                                                'readonly'    => true,
    … …  
    221221                                                        'decimal_sep'    => array(
    222222                                                                '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' ),
    224224                                                                'context'     => array( 'view' ),
    225225                                                                'readonly'    => true,
    … …  
    227227                                                        'dimension_unit' => array(
    228228                                                                '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' ),
    230230                                                                'context'     => array( 'view' ),
    231231                                                                'readonly'    => true,
    … …  
    233233                                                        'name'           => array(
    234234                                                                'type'        => 'string',
    235                                                                 'description' => __( 'Full name of country.', 'woocommerce' ),
     235                                                                'description' => __( 'Full name of country.', 'woocommerce-services' ),
    236236                                                                'context'     => array( 'view' ),
    237237                                                                'readonly'    => true,
    … …  
    239239                                                        'num_decimals'   => array(
    240240                                                                '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' ),
    242242                                                                'context'     => array( 'view' ),
    243243                                                                'readonly'    => true,
    … …  
    245245                                                        'states'         => array(
    246246                                                                'type'        => 'array',
    247                                                                 'description' => __( 'List of states in this country.', 'woocommerce' ),
     247                                                                'description' => __( 'List of states in this country.', 'woocommerce-services' ),
    248248                                                                'context'     => array( 'view' ),
    249249                                                                'readonly'    => true,
    … …  
    255255                                                                                'code' => array(
    256256                                                                                        'type'        => 'string',
    257                                                                                         'description' => __( 'State code.', 'woocommerce' ),
     257                                                                                        'description' => __( 'State code.', 'woocommerce-services' ),
    258258                                                                                        'context'     => array( 'view' ),
    259259                                                                                        'readonly'    => true,
    … …  
    261261                                                                                'name' => array(
    262262                                                                                        'type'        => 'string',
    263                                                                                         'description' => __( 'Full name of state.', 'woocommerce' ),
     263                                                                                        'description' => __( 'Full name of state.', 'woocommerce-services' ),
    264264                                                                                        'context'     => array( 'view' ),
    265265                                                                                        'readonly'    => true,
    … …  
    270270                                                        'thousand_sep'   => array(
    271271                                                                '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' ),
    273273                                                                'context'     => array( 'view' ),
    274274                                                                'readonly'    => true,
    … …  
    276276                                                        'weight_unit'    => array(
    277277                                                                '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' ),
    279279                                                                'context'     => array( 'view' ),
    280280                                                                'readonly'    => true,
  • woocommerce-services/tags/3.7.1/classes/wc-api-dev/class-wc-rest-dev-data-controller.php

    r2498257 r3732427  
    6767        public function get_items_permissions_check( $request ) {
    6868                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() ) );
    7070                }
    7171
    … …  
    8181        public function get_item_permissions_check( $request ) {
    8282                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() ) );
    8484                }
    8585
    … …  
    9999                        array(
    100100                                'slug'        => 'continents',
    101                                 'description' => __( 'List of supported continents, countries, and states.', 'woocommerce' ),
     101                                'description' => __( 'List of supported continents, countries, and states.', 'woocommerce-services' ),
    102102                        ),
    103103                        array(
    104104                                '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' ),
    106106                        ),
    107107                        array(
    108108                                'slug'        => 'currencies',
    109                                 'description' => __( 'List of supported currencies.', 'woocommerce' ),
     109                                'description' => __( 'List of supported currencies.', 'woocommerce-services' ),
    110110                        ),
    111111                );
    … …  
    174174                        'properties' => array(
    175175                                'slug'        => array(
    176                                         'description' => __( 'Data resource ID.', 'woocommerce' ),
     176                                        'description' => __( 'Data resource ID.', 'woocommerce-services' ),
    177177                                        'type'        => 'string',
    178178                                        'context'     => array( 'view' ),
    … …  
    180180                                ),
    181181                                'description' => array(
    182                                         'description' => __( 'Data resource description.', 'woocommerce' ),
     182                                        'description' => __( 'Data resource description.', 'woocommerce-services' ),
    183183                                        'type'        => 'string',
    184184                                        'context'     => array( 'view' ),
  • woocommerce-services/tags/3.7.1/i18n/languages/woocommerce-services.pot

    r3707890 r3732427  
    55"Project-Id-Version: _s woocommerce-services\n"
    66"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"
    88"MIME-Version: 1.0\n"
    99"Content-Type: text/plain; charset=UTF-8\n"
    … …  
    110110#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    111111#: dist/chunks/13f51058cf091ad1e827.min.js:1
    112 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     112#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    113113msgid "Create shipping label"
    114114msgid_plural "Create shipping labels"
    … …  
    117117
    118118#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    119 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     119#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    120120msgid "Track Package"
    121121msgid_plural "Track Packages"
    … …  
    124124
    125125#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    126 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     126#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    127127msgid "Create new label"
    128128msgstr ""
    129129
    130130#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    131 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     131#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    132132msgid "Connection error: unable to create label at this time"
    133133msgstr ""
    134134
    135135#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    136 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     136#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    137137msgid "%(itemCount)d item was fulfilled on {{span}}%(createdDate)s{{/span}}"
    138138msgid_plural ""
    … …  
    142142
    143143#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    144 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     144#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    145145msgid "%(itemCount)d item is ready to be fulfilled"
    146146msgid_plural "%(itemCount)d items are ready to be fulfilled"
    … …  
    804804#: dist/chunks/13f51058cf091ad1e827.min.js:1
    805805#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    806 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     806#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    807807msgid "Try WooCommerce Shipping"
    808808msgstr ""
    … …  
    810810#: dist/chunks/13f51058cf091ad1e827.min.js:1
    811811#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    812 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     812#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    813813msgid "A new WooCommerce Shipping experience is now available"
    814814msgstr ""
    … …  
    816816#: dist/chunks/13f51058cf091ad1e827.min.js:1
    817817#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    818 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     818#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    819819msgid ""
    820820"We'll ensure a seamless transition by allowing you to migrate all your "
    … …  
    824824#: dist/chunks/13f51058cf091ad1e827.min.js:1
    825825#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    826 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     826#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    827827msgid "Here's what you can expect from the new shipping experience:"
    828828msgstr ""
    … …  
    830830#: dist/chunks/13f51058cf091ad1e827.min.js:1
    831831#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    832 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     832#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    833833msgid "A seamless transition"
    834834msgstr ""
    … …  
    836836#: dist/chunks/13f51058cf091ad1e827.min.js:1
    837837#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    838 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     838#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    839839msgid ""
    840840"Automatically migrate all your compatible settings and shipment history to "
    … …  
    844844#: dist/chunks/13f51058cf091ad1e827.min.js:1
    845845#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    846 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     846#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    847847msgid "Print and save"
    848848msgstr ""
    … …  
    850850#: dist/chunks/13f51058cf091ad1e827.min.js:1
    851851#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    852 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     852#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    853853msgid ""
    854854"Speed up label creation with a streamlined process to print and save your "
    … …  
    858858#: dist/chunks/13f51058cf091ad1e827.min.js:1
    859859#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    860 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     860#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    861861msgid "USPS and DHL Express"
    862862msgstr ""
    … …  
    864864#: dist/chunks/13f51058cf091ad1e827.min.js:1
    865865#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    866 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     866#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    867867msgid ""
    868868"Ship your productss using trusted shipping carriers like USPS and DHL "
    … …  
    872872#: dist/chunks/13f51058cf091ad1e827.min.js:1
    873873#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    874 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     874#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    875875msgid "Enhanced label purchase flow"
    876876msgstr ""
    … …  
    878878#: dist/chunks/13f51058cf091ad1e827.min.js:1
    879879#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    880 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     880#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    881881msgid ""
    882882"Experience a smoother label purchasing process with our updated interface."
    … …  
    885885#: dist/chunks/13f51058cf091ad1e827.min.js:1
    886886#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    887 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     887#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    888888msgid "Maybe later"
    889889msgstr ""
  • woocommerce-services/tags/3.7.1/readme.txt

    r3707890 r3732427  
    77Tested up to: 7.1
    88WC requires at least: 10.9
    9 WC tested up to: 11.1
    10 Stable tag: 3.7.0
     9WC tested up to: 11.2
     10Stable tag: 3.7.1
    1111License: GPLv2 or later
    1212License URI: http://www.gnu.org/licenses/gpl-2.0.html
    … …  
    7171== Changelog ==
    7272
     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
    7398= 3.7.0 - 2026-09-22 =
    7499* Fix   - Prevent a fatal error that stopped WooCommerce Tax from being deactivated while WooCommerce was inactive.
    75100* Tweak - Update the bundled JavaScript libraries.
    76101* 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.
    77103
    78104= 3.6.16 - 2026-09-16 =
  • woocommerce-services/tags/3.7.1/src/Integrations/WooCommerceBlocksIntegration.php

    r3635005 r3732427  
    127127                        $script_url,
    128128                        $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.
    130130                        true
    131131                );
  • woocommerce-services/tags/3.7.1/src/StoreApi/StoreApiExtensionController.php

    r3331570 r3732427  
    1111
    1212use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
    13 use Exception;
     13use Throwable;
    1414
    1515defined( 'ABSPATH' ) || exit;
    … …  
    9393                                )
    9494                        );
    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                        );
    97103                }
    98104        }
    … …  
    111117                                )
    112118                        );
    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                        );
    115127                }
    116128        }
  • woocommerce-services/tags/3.7.1/src/StoreNotices/StoreNoticesController.php

    r3392766 r3732427  
    2121
    2222        /**
     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        /**
    2330         * Notifier instance.
    2431         *
    … …  
    3744                add_action( 'woocommerce_after_calculate_totals', array( $this, 'maybe_display_notices' ), 30 );
    3845                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' ) );
    3948        }
    4049
    … …  
    4756                }
    4857
     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
    4965                $this->notifier->print_notices();
    5066                $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;
    51116        }
    52117
  • woocommerce-services/tags/3.7.1/src/StoreNotices/StoreNoticesNotifier.php

    r3331570 r3732427  
    132132         */
    133133        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
    134138                $notices = WC()->session->get( self::WC_SESSION_KEY, array() );
    135139
  • woocommerce-services/tags/3.7.1/src/Tax/Address.php

    r3640567 r3732427  
    3838
    3939use WC_Customer;
     40use WC_Order;
    4041use WP_Error;
    4142
    … …  
    267268
    268269        /**
     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        /**
    269299         * Build from an admin-order `$_POST` payload.
    270300         *
  • woocommerce-services/tags/3.7.1/src/Utils.php

    r3350055 r3732427  
    6868         */
    6969        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.
    7071                return sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ?? $default ) );
    7172        }
  • woocommerce-services/tags/3.7.1/vendor/composer/autoload_classmap.php

    r3698279 r3732427  
    9494    'Automattic\\WCServices\\StoreNotices\\StoreNoticesNotifier' => $baseDir . '/src/StoreNotices/StoreNoticesNotifier.php',
    9595    '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',
    9698    'Automattic\\WCServices\\Utils' => $baseDir . '/src/Utils.php',
    9799    'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
  • woocommerce-services/tags/3.7.1/vendor/composer/autoload_static.php

    r3707890 r3732427  
    119119        'Automattic\\WCServices\\StoreNotices\\StoreNoticesNotifier' => __DIR__ . '/../..' . '/src/StoreNotices/StoreNoticesNotifier.php',
    120120        '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',
    121123        'Automattic\\WCServices\\Utils' => __DIR__ . '/../..' . '/src/Utils.php',
    122124        'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
  • woocommerce-services/tags/3.7.1/vendor/composer/installed.php

    r3707890 r3732427  
    44        'pretty_version' => 'dev-trunk',
    55        'version' => 'dev-trunk',
    6         'reference' => '52b37612e215ad51a541900cf94eca91c8691fee',
     6        'reference' => 'd726c3d7abe4c8fa65a77d8cde0a81087380213a',
    77        'type' => 'library',
    88        'install_path' => __DIR__ . '/../../',
    … …  
    1414            'pretty_version' => 'dev-trunk',
    1515            'version' => 'dev-trunk',
    16             'reference' => '52b37612e215ad51a541900cf94eca91c8691fee',
     16            'reference' => 'd726c3d7abe4c8fa65a77d8cde0a81087380213a',
    1717            'type' => 'library',
    1818            'install_path' => __DIR__ . '/../../',
  • woocommerce-services/tags/3.7.1/vendor/composer/jetpack_autoload_classmap.php

    r3698279 r3732427  
    367367                'path'    => $baseDir . '/src/Tax/Address.php'
    368368        ),
     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        ),
    369377        'Automattic\\WCServices\\Utils' => array(
    370378                'version' => 'dev-trunk',
  • woocommerce-services/tags/3.7.1/woocommerce-services.php

    r3707890 r3732427  
    99 * Text Domain: woocommerce-services
    1010 * 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
    1214 * Requires Plugins: woocommerce
    1315 * Requires PHP: 7.4
    … …  
    1517 * Tested up to: 7.1
    1618 * WC requires at least: 10.9
    17  * WC tested up to: 11.1
     19 * WC tested up to: 11.2
    1820 *
    1921 * Copyright (c) 2017-2023 Automattic
    … …  
    9496                 */
    9597                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;
    96105
    97106                /**
    … …  
    13851394                 * Note: intended to be hooked into 'rest_request_before_callbacks'
    13861395                 *
    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.
    13901405                 *
    13911406                 * @return mixed - pass through value of $response.
    13921407                 */
    13931408                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 ) {
    13951410                                return $response;
    13961411                        }
    13971412
    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() ) );
    14031426                        }
    14041427
    14051428                        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 ? '...' : '' );
    14061489                }
    14071490
    … …  
    17831866
    17841867                        // 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.
    17851869                        if ( isset( $_GET['action'] ) ) {
    17861870                                return false;
    … …  
    18391923                        }
    18401924
    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.
    18441928
    18451929                        $i18n_json = $this->get_i18n_json();
    … …  
    18691953
    18701954                        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.
    18711956                                $is_active = $wpdb->get_var(
    18721957                                        $wpdb->prepare(
    … …  
    20712156                        $meta_table_to_check = OrderUtil::get_table_for_order_meta();
    20722157
     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.
    20732159                        return (bool) $wpdb->get_var(
    20742160                                $wpdb->prepare(
    … …  
    21682254                                'https://cdn.sift.com/s.js',
    21692255                                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.
    21712257                                array(
    21722258                                        'strategy'  => 'defer',
    … …  
    22522338                        $encoded_arguments = wp_json_encode( $extra_args );
    22532339                        ?>
    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. ?>">
    22552341                                        <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                                                ?>
    22572346                                        </span>
    22582347                                </div>
    … …  
    23892478                                sprintf(
    23902479                                        /* 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.
    23922481                                        'https://woocommerce.com/document/woocommerce-shipping-and-tax/woocommerce-shipping/#how-do-i-migrate-from-wcst'
    23932482                                ) .
    … …  
    24072496                        $plugin_version = self::get_wcs_version();
    24082497                        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.
    24102499                        wp_localize_script(
    24112500                                'wcst_wcshipping_migration_admin_notice',
  • woocommerce-services/trunk/classes/class-wc-connect-api-client-live.php

    r3598126 r3732427  
    7171                                                'wcc_server_error',
    7272                                                sprintf(
     73                                                        /* translators: %d: HTTP response code */
    7374                                                        __( 'Error: The WooCommerce Tax server returned HTTP code: %d', 'woocommerce-services' ),
    7475                                                        $response_code
    … …  
    9293                                                'wcc_server_empty_response',
    9394                                                sprintf(
     95                                                        /* translators: %d: HTTP response code */
    9496                                                        __( 'Error: The WooCommerce Tax server returned ( %d ) and an empty response body.', 'woocommerce-services' ),
    9597                                                        $response_code
  • woocommerce-services/trunk/classes/class-wc-connect-api-client.php

    r3452871 r3732427  
    9797                                                        'product_missing_weight',
    9898                                                        sprintf(
     99                                                                /* translators: %d: product ID */
    99100                                                                __( 'Product ( ID: %d ) did not include a weight. Shipping rates cannot be calculated.', 'woocommerce-services' ),
    100101                                                                $product->get_id()
    … …  
    112113                                                        'product_missing_dimension',
    113114                                                        sprintf(
     115                                                                /* translators: %d: product ID */
    114116                                                                __( 'Product ( ID: %d ) is missing a dimension value. Shipping rates cannot be calculated.', 'woocommerce-services' ),
    115117                                                                $product->get_id()
    … …  
    616618
    617619                        $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.
    620622                                'method'      => $method,
    621623                        );
    … …  
    670672                                sprintf(
    671673                                        '%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 )
    680682                                )
    681683                        );
  • woocommerce-services/trunk/classes/class-wc-connect-custom-surcharge.php

    r3417327 r3732427  
    112112                                array(
    113113                                        'value' => 0.28,
    114                                         'text'  => __( 'Retail Delivery Fee', 'woocommerce_services' ),
     114                                        'text'  => __( 'Retail Delivery Fee', 'woocommerce-services' ),
    115115                                ),
    116116                                $cart
  • woocommerce-services/trunk/classes/class-wc-connect-debug-tools.php

    r3289651 r3732427  
    9090                        global $wpdb;
    9191
     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.
    9293                        $found_ca_rates = $wpdb->get_results(
    9394                                $wpdb->prepare(
    … …  
    124125
    125126                        echo '<div class="updated inline"><p>';
     127                        /* translators: %1$d: number of deleted rows */
    126128                        printf( esc_html__( 'Successfully deleted %1$d rows from the database.', 'woocommerce-services' ), intval( $deleted_count ) );
    127129                        echo '</p></div>';
    … …  
    137139
    138140                        $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.
    139142                                $wpdb->query(
    140143                                        "DELETE FROM {$wpdb->options} WHERE option_name LIKE '%tj\_tax\_%';"
    … …  
    143146
    144147                        echo '<div class="updated inline"><p>';
     148                        /* translators: %1$d: number of deleted transients */
    145149                        printf( esc_html__( 'Successfully deleted %1$d transients from the database.', 'woocommerce-services' ), intval( $deleted_count ) );
    146150                        echo '</p></div>';
  • woocommerce-services/trunk/classes/class-wc-connect-error-notice.php

    r3698279 r3732427  
    7878
    7979                private function notice_enabled() {
    80                          return WC_Connect_Options::get_option( 'error_notice', false );
     80                        return WC_Connect_Options::get_option( 'error_notice', false );
    8181                }
    8282
    … …  
    119119                                $product_id   = $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
    120120                                $message      = sprintf(
     121                                        /* translators: %1$s: product edit URL, %2$s: product name */
    121122                                        __( '<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' ),
    122123                                        get_edit_post_link( $product_id ),
  • woocommerce-services/trunk/classes/class-wc-connect-extension-compatibility.php

    r3310537 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Extension_Compatibility' ) ) {
  • woocommerce-services/trunk/classes/class-wc-connect-functions.php

    r3685245 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Functions' ) ) {
    … …  
    1923                                ( defined( 'REST_REQUEST' ) || defined( 'REST_API_REQUEST' ) || defined( 'XMLRPC_REQUEST' ) ) ||
    2024                                // 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.
    2126                                ( isset( $_GET['rest_route'] ) && isset( $_GET['_for'] ) && ( 'jetpack' === $_GET['_for'] ) )
    2227                        );
    … …  
    153158
    154159                        // 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.
    155161                        $rates = $wpdb->get_results(
    156162                                $wpdb->prepare(
    … …  
    170176                        $rows = array(
    171177                                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' ),
    182188                                ),
    183189                        );
    184190
    185191                        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.
    186193                                $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 ) );
    188196
    189197                                $rows[] = array(
    … …  
    338346                        foreach ( $files as $file ) {
    339347                                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_fopen
     348                                        $file_handle = @fopen( trailingslashit( $file['base'] ) . $file['file'], 'wb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen
    341349                                        if ( $file_handle ) {
    342                                                 fwrite( $file_handle, $file['content'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fwrite
    343                                                 fclose( $file_handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_fclose
     350                                                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
    344352                                        }
    345353                                }
  • woocommerce-services/trunk/classes/class-wc-connect-help-view.php

    r3505023 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Help_View' ) ) {
    … …  
    5963                                        'state'   => 'error',
    6064                                        'message' => sprintf(
     65                                                /* translators: %1$s: minimum required WooCommerce version, %2$s: installed WooCommerce version */
    6166                                                __( 'WooCommerce %1$s or higher is required (You are running %2$s)', 'woocommerce-services' ),
    6267                                                WOOCOMMERCE_CONNECT_MINIMUM_WOOCOMMERCE_VERSION,
    … …  
    7378                                        'state'   => 'success',
    7479                                        'message' => sprintf(
     80                                                /* translators: %s: installed WooCommerce version */
    7581                                                __( 'WooCommerce %s is configured correctly', 'woocommerce-services' ),
    7682                                                WC()->version
    … …  
    166172
    167173                                $subtitle = sprintf(
     174                                        /* translators: %s: shipping zone name */
    168175                                        __( '%s Shipping Zone', 'woocommerce-services' ),
    169176                                        $enabled_service->zone_name
    … …  
    329336                                        'state'              => 'error',
    330337                                        'settings_link_type' => '',
     338                                        /* translators: %s: store country code */
    331339                                        'message'            => sprintf( __( 'Your store\'s country (%s) is not supported. Automated taxes functionality is disabled', 'woocommerce-services' ), $store_country ),
    332340                                );
  • woocommerce-services/trunk/classes/class-wc-connect-jetpack.php

    r3685245 r3732427  
    55use Automattic\Jetpack\Status;
    66use Automattic\Jetpack\Status\Host;
     7
     8if ( ! defined( 'ABSPATH' ) ) {
     9        exit;
     10}
    711
    812if ( ! class_exists( 'WC_Connect_Jetpack' ) ) {
  • woocommerce-services/trunk/classes/class-wc-connect-label-reports.php

    r3204770 r3732427  
    22
    33use Automattic\WooCommerce\Utilities\OrderUtil;
     4
     5if ( ! defined( 'ABSPATH' ) ) {
     6        exit;
     7}
    48
    59if ( ! class_exists( 'WC_Connect_Label_Reports' ) ) {
    … …  
    1923
    2024                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';
    2227                        ?>
    2328                        <a
    … …  
    4146                        $table_name = OrderUtil::get_table_for_order_meta();
    4247                        $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.
    4349                        $db_results = $wpdb->get_results(
    4450                                $wpdb->prepare(
    … …  
    129135                        );
    130136
    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';
    132139
    133140                        if ( ! in_array( $current_range, array( 'custom', 'year', 'last_month', 'month', '7day' ) ) ) {
    … …  
    198205                                                        <tr>
    199206                                                                <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 ) ) ) ); ?>
    201208                                                                </th>
    202209                                                                <td>
  • woocommerce-services/trunk/classes/class-wc-connect-logger.php

    r3452871 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Logger' ) ) {
    … …  
    105109                 *
    106110                 * @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)
    108112                 */
    109113                public function log( $message, $context = '', $force = false ) {
    … …  
    111115
    112116                        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.
    114118                                error_log( $log_message );
    115119                        }
  • woocommerce-services/trunk/classes/class-wc-connect-migration-survey.php

    r3350055 r3732427  
    88
    99use Automattic\WCServices\Utils;
     10
     11if ( ! defined( 'ABSPATH' ) ) {
     12        exit;
     13}
    1014
    1115if ( ! class_exists( 'WC_Connect_Migration_Survey' ) ) {
    … …  
    4246                public function should_show_survey() {
    4347                        // 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.
    4449                        if ( isset( $_GET['force_survey'] ) ) {
    4550                                return true;
    … …  
    110115
    111116                        // 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;
    113119
    114120                        if ( ! $survey_data ) {
  • woocommerce-services/trunk/classes/class-wc-connect-nux.php

    r3685245 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Nux' ) ) {
    … …  
    1822                 */
    1923                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';
    2034
    2135                /**
    … …  
    136150                                global $wpdb;
    137151
     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.
    138153                                $results     = $wpdb->get_results(
    139154                                        $wpdb->prepare(
    … …  
    175190                                                '<h3>%s</h3><p>%s</p>',
    176191                                                __( 'Discounted Shipping Labels', 'woocommerce-services' ),
     192                                                /* translators: %s: list of supported carriers */
    177193                                                sprintf( __( "When you're ready, purchase and print discounted labels from %s right here.", 'woocommerce-services' ), implode( ' or ', $supported_carriers ) )
    178194                                        ),
    … …  
    300316
    301317                /**
     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                /**
    302333                 * Whether the WooCommerce Tax connection banner may render on the given screen.
    303334                 *
    … …  
    320351
    321352                        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.
    322354                                && isset( $_GET['tab'] ) && 'tax' === $_GET['tab'];
    323355                }
    … …  
    385417                        // If this is the case, the admin can connect the site on their own, and should be able to use WCS as ususal
    386418                        $jetpack_install_status = $this->get_jetpack_install_status();
     419                        $can_accept_tos         = self::current_user_can_accept_tos();
    387420
    388421                        $banner_to_display = self::get_banner_type_to_display(
    … …  
    390423                                        'jetpack_connection_status'       => $jetpack_install_status,
    391424                                        '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 ),
    394428                                        'is_site_only_connection'         => WC_Connect_Jetpack::is_site_only_connection(),
    395429                                )
    … …  
    518552                }
    519553
     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
    520572                public function show_banner_after_connection() {
    521573                        if ( ! $this->should_display_nux_notice_for_current_store_locale() ) {
    … …  
    528580
    529581                        // 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 ) ) {
    531585                                // No longer need to keep track of whether the before connection banner was displayed.
    532586                                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' ) ) );
    534588                                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;
    535597                        }
    536598
    … …  
    550612                                        'description'       => esc_html( sprintf( $description_base, $feature_list ) ),
    551613                                        '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
    556621                                        ),
    557622                                        'image_url'         => plugins_url(
    … …  
    573638                        }
    574639
    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 ) ) {
    576651                                WC_Connect_Options::update_option( 'tos_accepted', true );
    577652
    578653                                $this->tracks->opted_in( 'tos_banner' );
    579654
    580                                 wp_safe_redirect( remove_query_arg( 'wcs-nux-tos' ) );
     655                                wp_safe_redirect( remove_query_arg( array( 'wcs-nux-tos', '_wpnonce' ) ) );
    581656                                exit;
    582657                        }
    … …  
    592667                                        'description'       => esc_html( sprintf( $description_base, $feature_list ) ),
    593668                                        '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
    598676                                        ),
    599677                                        'image_url'         => plugins_url(
    … …  
    653731                                                <p class="wcs-nux__notice-content-tos">
    654732                                                        <?php
    655                                                         /* translators: %1$s example values include "Install Jetpack and CONNECT >", "Activate Jetpack and CONNECT >", "CONNECT >" */
    656733                                                        printf(
    657734                                                                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 */
    658736                                                                        __( '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' ),
    659737                                                                        array(
  • woocommerce-services/trunk/classes/class-wc-connect-options.php

    r3452871 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Options' ) ) {
    … …  
    138142                        $names  = (array) $names;
    139143                        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 );
    141145                                return false;
    142146                        }
    … …  
    332336                private static function delete_all_shipping_methods_options() {
    333337                        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.
    334339                        $methods = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}woocommerce_shipping_zone_methods " );
    335340
  • woocommerce-services/trunk/classes/class-wc-connect-order-presenter.php

    r2818899 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Order_Presenter' ) ) {
  • woocommerce-services/trunk/classes/class-wc-connect-payment-gateway.php

    r1586050 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Payment_Gateway' ) ) {
    … …  
    1216
    1317                        $this->init_settings();
    14 
    1518                }
    16 
    1719        }
    1820
    1921}
    20 
  • woocommerce-services/trunk/classes/class-wc-connect-payment-methods-store.php

    r3097468 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Payment_Methods_Store' ) ) {
    … …  
    2024                protected $logger;
    2125
    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                ) {
    2431
    2532                        $this->service_settings_store = $service_settings_store;
    2633                        $this->api_client             = $api_client;
    2734                        $this->logger                 = $logger;
    28 
    2935                }
    3036
  • woocommerce-services/trunk/classes/class-wc-connect-paypal-ec.php

    r3289651 r3732427  
    135135                                                sprintf(
    136136                                                        wp_kses(
     137                                                                /* translators: %s: PayPal signup URL */
    137138                                                                __( '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' ),
    138139                                                                array( 'a' => array( 'href' => array() ) )
    … …  
    194195                                || ( // WooCommerce » Settings » Payments.
    195196                                        '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.
    196198                                        && isset( $_GET['tab'] ) && 'checkout' === $_GET['tab']
    197199                                        )
    198200                                || ( // WooCommerce » Extensions » Payments.
    199201                                        '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.
    200203                                        && isset( $_GET['section'] ) && 'payment-gateways' === $_GET['section']
    201204                                        )
    … …  
    267270
    268271                                // 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 */
    270274                                $form_fields['paymentaction']['description'] = sprintf( __( '%s (Note that "authorizing payment only" requires linking a PayPal account.)', 'woocommerce-services' ), $form_fields['paymentaction']['description'] );
    271275
    272276                                // Communicate WCS proxying and provide option to disable
    273                                 $reset_link         = add_query_arg(
     277                                $reset_link = add_query_arg(
    274278                                        array(
    275279                                                'reroute_requests' => 'no',
    … …  
    278282                                        wc_gateway_ppec()->get_admin_setting_link()
    279283                                );
     284                                /* translators: %s: URL that resets the PayPal API credentials */
    280285                                $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' );
    281286                                if ( empty( $settings->api_username ) ) {
    … …  
    291296                        } else {
    292297                                // Provide option to enable request proxying
    293                                 $reset_link         = add_query_arg(
     298                                $reset_link = add_query_arg(
    294299                                        array(
    295300                                                'reroute_requests' => 'yes',
    … …  
    298303                                        wc_gateway_ppec()->get_admin_setting_link()
    299304                                );
     305                                /* translators: %s: URL that resets the PayPal API credentials */
    300306                                $api_creds_template = __( 'To authenticate payments with WooCommerce Tax, <a href="%s">click here</a>.', 'woocommerce-services' );
    301307                                if ( empty( $settings->api_username ) ) {
    … …  
    339345                                empty( $_GET['reroute_requests'] ) ||
    340346                                empty( $_GET['nonce'] ) ||
    341                                 ! wp_verify_nonce( $_GET['nonce'], 'reroute_requests' ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
     347                                ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['nonce'] ) ), 'reroute_requests' )
    342348                        ) {
    343349                                return;
  • woocommerce-services/trunk/classes/class-wc-connect-privacy.php

    r3289651 r3732427  
    3838                        sprintf(
    3939                                wp_kses(
     40                                        /* translators: %s: URL of the privacy documentation */
    4041                                        __( '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' ),
    4142                                        array(
  • woocommerce-services/trunk/classes/class-wc-connect-service-schemas-store.php

    r3289651 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Service_Schemas_Store' ) ) {
    … …  
    209213                public function get_service_schema_by_instance_id( $instance_id ) {
    210214                        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.
    211216                        $method_id = $wpdb->get_var(
    212217                                $wpdb->prepare(
  • woocommerce-services/trunk/classes/class-wc-connect-service-schemas-validator.php

    r2498257 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Service_Schemas_Validator' ) ) {
    … …  
    4549                                }
    4650
    47                                 $service_counter ++;
     51                                ++$service_counter;
    4852                        }
    4953
    … …  
    190194                        return true;
    191195                }
    192 
    193196        }
    194197
  • woocommerce-services/trunk/classes/class-wc-connect-service-settings-store.php

    r3327549 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Service_Settings_Store' ) ) {
    … …  
    262266                 * Updates the existing label data
    263267                 *
    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.
    268273                 */
    269274                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
    272283                        $labels_data = $this->get_label_order_meta_data( $order_id );
     284                        $matched     = false;
    273285                        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;
    275288                                        $result                = array_merge( $label_data, (array) $new_label_data );
    276289                                        $labels_data[ $index ] = $result;
    … …  
    282295                                }
    283296                        }
     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
    284303                        $order->update_meta_data( 'wc_connect_labels', $labels_data );
    285304                        $order->save();
    … …  
    291310                 *
    292311                 * @param $order_id
    293                  * @param array    $new_labels - labels to be added
     312                 * @param array $new_labels - labels to be added
    294313                 */
    295314                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
    296322                        $labels_data = $this->get_label_order_meta_data( $order_id );
    297323                        $labels_data = array_merge( $new_labels, $labels_data );
    298                         $order       = wc_get_order( $order_id );
    299324                        $order->update_meta_data( 'wc_connect_labels', $labels_data );
    300325                        $order->save();
    … …  
    306331
    307332                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
    309340                        $wc_address = $order->get_address( 'shipping' );
    310341
    … …  
    361392
    362393                public function get_enabled_services_by_ids( $service_ids ) {
    363                         if ( empty( $service_ids ) ) {
     394                        if ( empty( $service_ids ) || ! is_array( $service_ids ) ) {
    364395                                return array();
    365396                        }
    … …  
    367398                        $enabled_services = array();
    368399
    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' ) );
    379404
    380405                        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.
    382408                        $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                                )
    388417                        );
    389                         // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     418                        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
    390419
    391420                        if ( empty( $methods ) ) {
    … …  
    445474                                $new_method_id = $service_schema->method_id;
    446475
     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.
    447477                                $wpdb->update(
    448478                                        "{$wpdb->prefix}woocommerce_shipping_zone_methods",
  • woocommerce-services/trunk/classes/class-wc-connect-settings-pages.php

    r3310537 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Settings_Pages' ) ) {
    … …  
    115119                        }
    116120
     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.
    117122                        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'] ) );
    119125                                $extra_args['order_id']   = $from_order;
    120126                                $extra_args['order_href'] = get_edit_post_link( $from_order );
    121127                        }
    122128
     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.
    123130                        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'] ) );
    125133                                $extra_args['carrier']    = $carrier;
    126134                                $extra_args['continents'] = $this->continents->get();
  • woocommerce-services/trunk/classes/class-wc-connect-shipping-label.php

    r3350055 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Shipping_Label' ) ) {
  • woocommerce-services/trunk/classes/class-wc-connect-shipping-method.php

    r3327549 r3732427  
    11<?php
     2
     3if ( ! defined( 'ABSPATH' ) ) {
     4        exit;
     5}
    26
    37if ( ! class_exists( 'WC_Connect_Shipping_Method' ) ) {
  • woocommerce-services/trunk/classes/class-wc-connect-taxjar-integration.php

    r3679638 r3732427  
    33use Automattic\WCServices\StoreNotices\StoreNoticesNotifier;
    44use Automattic\WCServices\Tax\Address;
     5use Automattic\WCServices\Tax\StoreAddressNotice;
     6use Automattic\WCServices\Tax\StoreAddressVerifier;
    57
    68class WC_Connect_TaxJar_Integration {
    … …  
    1719
    1820        /**
    19          * @var StoreNoticesNotifier
     21         * Shows customer-input errors at checkout. Optional: null when none is passed to the constructor.
     22         *
     23         * @var StoreNoticesNotifier|null
    2024         */
    2125        private $notifier;
    … …  
    6872
    6973        /**
     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        /**
    7084         * Tax snapshots captured before an out-of-cart recalculation, keyed by order id,
    7185         * so they can be restored after WC recalculates the order totals.
    … …  
    7488         */
    7589        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        );
    76139
    77140        /**
    … …  
    108171        const OPTION_NAME              = 'wc_connect_taxes_enabled';
    109172        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        );
    110194
    111195        /**
    … …  
    182266        }
    183267
     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
    184295        public function init() {
    185296                // Only enable WCS TaxJar integration if the official TaxJar plugin isn't active.
    … …  
    226337                $this->configure_tax_settings();
    227338
     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
    228344                // Calculate Taxes at Cart / Checkout
    229345                if ( class_exists( 'WC_Cart_Totals' ) ) { // Woo 3.2+
    … …  
    238354                // Preserve recorded taxes when an existing order is recalculated outside the
    239355                // 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.
    241359                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 );
    242367
    243368                // Set customer taxable location for local pickup
    … …  
    284409                $backedup_tax_rates_url = admin_url( '/admin.php?page=wc-status&tab=connect#tax-rate-backups' );
    285410
     411                /* translators: %1$s: opening link tag, %2$s: closing link tag */
    286412                $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>';
    287413
     414                /* translators: %1$s: opening link tag, %2$s: closing link tag */
    288415                $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>' : '';
    289416
    290417                $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 */
    291419                $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>';
    292420
    … …  
    357485        public function fix_tooltip_keepalive() {
    358486                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.
    359488                if ( 'admin.php' !== $pagenow || ! isset( $_GET['page'] ) || 'wc-settings' !== $_GET['page'] || ! isset( $_GET['tab'] ) || 'tax' !== $_GET['tab'] || ! empty( $_GET['section'] ) ) {
    360489                        return;
    … …  
    509638                $formatted_message = is_scalar( $message ) ? $message : json_encode( $message );
    510639
    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.
    512643                $state_zip_mismatch = false !== strpos( $formatted_message, 'to_zip' ) && false !== strpos( $formatted_message, 'is not used within to_state' );
    513644                $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
    516656                        $fields              = WC()->countries->get_address_fields();
    517657                        $postcode_field_name = __( 'ZIP/Postal code', 'woocommerce-services' );
    … …  
    521661
    522662                        if ( $state_zip_mismatch ) {
     663                                /* translators: %s: ZIP/Postal code checkout field label */
    523664                                $message = sprintf( _x( '%s does not match the selected state.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name );
    524665                        } elseif ( $malformed_postcode ) {
     666                                /* translators: %s: ZIP/Postal code checkout field label */
    525667                                $message = sprintf( _x( '%s is not formatted correctly.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name );
    526668                        } else {
     669                                /* translators: %s: ZIP/Postal code checkout field label */
    527670                                $message = sprintf( _x( 'Invalid %s entered.', '%s - ZIP/Postal code checkout field label', 'woocommerce-services' ), $postcode_field_name );
    528671                        }
    … …  
    653796         * Calculate tax / totals using TaxJar for backend orders
    654797         *
    655          * Unchanged from the TaxJar plugin.
     798         * Based on the TaxJar plugin.
    656799         * See: https://github.com/taxjar/taxjar-woocommerce-plugin/blob/96b5d57/includes/class-wc-taxjar-integration.php#L557
    657800         *
    … …  
    660803        public function calculate_backend_totals( $order_id ) {
    661804                $order      = wc_get_order( $order_id );
    662                 $address    = $this->get_backend_address();
     805                $address    = $this->fill_missing_backend_street( $this->get_backend_address(), $order );
    663806                $line_items = $this->get_backend_line_items( $order );
    664807                if ( method_exists( $order, 'get_shipping_total' ) ) {
    … …  
    678821                        )
    679822                );
    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;
    694838                                }
    695839                        }
    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                 */
    703848        }
    704849
    … …  
    9691114
    9701115        /**
     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        /**
    9711190         * Get line items at checkout
    9721191         *
    … …  
    9911210                        $line_subtotal = wc_format_decimal( $cart_item['line_subtotal'] );
    9921211                        $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() );
    9991213
    10001214                        if ( 'shipping' !== $product->get_tax_status() && ( ! $product->is_taxable() || 'zero-rate' == sanitize_title( $product->get_tax_class() ) ) ) {
    … …  
    11141328                        }
    11151329                        $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 );
    11211331                        if ( 'taxable' !== $tax_status ) {
    11221332                                $tax_code = '99999';
    … …  
    11261336                                // matching note in get_line_items() for why this is recorded, not derived.
    11271337                                $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';
    11281342                        }
    11291343
    … …  
    14491663         */
    14501664        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
    14511672                // Only act if we have TaxJar-calculated rate IDs from this request.
    14521673                if ( empty( $this->response_rate_ids ) || ! is_array( $this->response_rate_ids ) ) {
    … …  
    14801701                }
    14811702
     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 ) {
    14821740                // Build tax rates array from the stored rate IDs.
    14831741                $tax_rates = array();
    1484                 foreach ( $matching_rate_ids as $rate_id ) {
     1742                foreach ( $rate_ids as $rate_id ) {
    14851743                        $rate_id = absint( $rate_id );
    14861744                        if ( ! $rate_id ) {
    … …  
    17231981
    17241982                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' );
    17261984
    17271985                        return false;
    … …  
    17452003
    17462004                        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' );
    17512008
    17522009                                return false;
    … …  
    17842041                        $errors = str_replace( '[postcode]', '[zip]', $errors );
    17852042
    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' );
    17872044
    17882045                        return false;
    … …  
    18282085                        ( empty( $destination->postcode() ) && ! in_array( $destination->country(), WC()->countries->get_vat_countries(), true ) ) ||
    18292086                        ( 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() )
    18312089                ) {
    18322090                        $this->_log( 'Destination address or cart data is incomplete, or the customer is VAT exempt. Aborting.' );
    … …  
    20452303                        );
    20462304
     2305                        // Priorities written per tax class, for shadow_unscoped_rates_above_components().
     2306                        $written = array();
     2307
    20472308                        // Add line item tax rates.
    20482309                        foreach ( $taxes['line_items'] as $line_item_key => $line_item ) {
    … …  
    20732334                                                continue;
    20742335                                        }
     2336                                        $rate_name = self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions );
     2337
    20752338                                        $taxes['rate_ids'][ $line_item_key ][] = $this->create_or_update_tax_rate(
    20762339                                                $location,
    … …  
    20792342                                                $taxes['freight_taxable'],
    20802343                                                $priority,
    2081                                                 self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions )
     2344                                                $rate_name
    20822345                                        );
    20832346
     2347                                        $this->record_written_priority( $written, $tax_class, $priority, $rate_name );
    20842348                                        ++$priority;
    20852349                                }
    … …  
    20932357                                        continue;
    20942358                                }
     2359                                $rate_name = self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions );
     2360
    20952361                                $taxes['rate_ids']['shipping'][] = $this->create_or_update_tax_rate(
    20962362                                        $location,
    … …  
    20992365                                        $taxes['freight_taxable'],
    21002366                                        $priority,
    2101                                         self::generate_itemized_tax_rate_name( $tax_rate_name, $to_country, $jurisdictions )
     2367                                        $rate_name
    21022368                                );
    21032369
     2370                                $this->record_written_priority( $written, '', $priority, $rate_name );
    21042371                                ++$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                                }
    21052378                        }
    21062379                }
    … …  
    21782451                 * it is handed.
    21792452                 */
    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 );
    21942454
    21952455                /**
    … …  
    22382498                }
    22392499
     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
    22402527                if ( ! empty( $wc_rate ) ) {
    22412528                        $this->_log( ':: Tax Rate Found ::' );
    … …  
    22702557
    22712558                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                }
    22722700        }
    22732701
    … …  
    23712799
    23722800                // 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                }
    23742804
    23752805                if ( false === $response ) {
    … …  
    25112941
    25122942                // 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.
    25132944                if ( 'woocommerce_page_wc-orders' === $wc_order_screen_id && ! isset( $_GET['action'] ) ) {
    25142945                        return false;
    … …  
    25262957                }
    25272958                // 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.
    25292960        }
    25302961
    … …  
    25632994
    25642995                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();
    25673064        }
    25683065
    … …  
    25743071         * the checkout address, so when a REST API or programmatic update changes the
    25753072         * 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.
    25833092         *
    25843093         * Restoration happens on woocommerce_order_after_calculate_totals, the only post-
    … …  
    25993108         */
    26003109        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() ] );
    26103115                        return;
    26113116                }
    26123117
    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'] ) ) {
    26153124                        return;
    26163125                }
    26173126
    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'] ) {
    26283145                        return;
    26293146                }
    … …  
    26413158         *
    26423159         * 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 the
     3160         * recalculated order and, if one is present, applies it and drops it from the
    26443161         * pending set. Keyed by order id so a batch that recalculates several orders
    26453162         * restores each from its own snapshot.
    26463163         *
     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         *
    26473176         * @internal Hooked to woocommerce_order_after_calculate_totals.
    26483177         *
    … …  
    26603189                unset( $this->pre_recalculation_tax_snapshots[ $order_id ] );
    26613190
    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                }
    26633353        }
    26643354
    … …  
    27733463                $order->save();
    27743464        }
     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        }
    27754404}
  • woocommerce-services/trunk/classes/class-wc-connect-tracks.php

    r3452871 r3732427  
    9494                        }
    9595
    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'] ) ) : '';
    9999                        $data['blog_url']        = get_option( 'siteurl' );
    100100                        $data['blog_id']         = $jetpack_blog_id;
    … …  
    116116                        }
    117117                }
    118 
    119118        }
    120119
  • woocommerce-services/trunk/classes/class-wc-rest-connect-account-settings-controller.php

    r2498257 r3732427  
    5858                                'save_failed',
    5959                                sprintf(
     60                                        /* translators: %s: error message */
    6061                                        __( 'Unable to update settings. %s', 'woocommerce-services' ),
    6162                                        $result->get_error_message()
  • woocommerce-services/trunk/classes/class-wc-rest-connect-base-controller.php

    r2556587 r3732427  
    153153        }
    154154
     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        }
    155192}
  • woocommerce-services/trunk/classes/class-wc-rest-connect-packages-controller.php

    r2498257 r3732427  
    6565                if ( ! empty( $custom_packages ) ) {
    6666                        // Validate that the new custom packages have unique names.
    67                         $map_package_name            = function( $package ) {
     67                        $map_package_name            = function ( $package ) {
    6868                                return $package['name'];
    6969                        };
    … …  
    7575                                $error                   = array(
    7676                                        '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' ),
    7878                                        'data'    => array( 'package_names' => array_values( $duplicate_package_names ) ),
    7979                                );
    … …  
    8989                                $error = array(
    9090                                        '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' ),
    9292                                        'data'    => array( 'package_names' => array_values( $duplicate_package_names ) ),
    9393                                );
    … …  
    116116                                $error = array(
    117117                                        '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' ),
    119119                                        'data'    => array( 'package_names_by_carrier' => $duplicate_package_names_by_carrier ),
    120120                                );
    … …  
    137137                                $error = array(
    138138                                        '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' ),
    140140                                        'data'    => array( 'package_names_by_carrier' => $duplicate_package_names_by_carrier ),
    141141                                );
  • woocommerce-services/trunk/classes/class-wc-rest-connect-services-controller.php

    r2498257 r3732427  
    9090                                'validation_failed',
    9191                                sprintf(
     92                                        /* translators: %s: validation error message */
    9293                                        __( 'Unable to update service settings. Validation failed. %s', 'woocommerce-services' ),
    9394                                        $validation_result->get_error_message()
    … …  
    101102                return new WP_REST_Response( array( 'success' => true ), 200 );
    102103        }
    103 
    104104}
  • woocommerce-services/trunk/classes/class-wc-rest-connect-shipping-label-controller.php

    r3298671 r3732427  
    4343
    4444        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.
    4552                $settings             = $request->get_json_params();
    46                 $order_id             = $request['order_id'];
     53                $order_id             = (string) $order->get_id();
    4754                $settings['order_id'] = $order_id;
    4855
    … …  
    147154                                        $product_names[] = $product->get_title();
    148155                                } else {
    149                                         $order           = wc_get_order( $order_id );
    150156                                        $product_names[] = WC_Connect_Utils::get_product_name_from_order( $product_id, $order );
    151157                                }
    … …  
    158164                }
    159165
    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 );
    161167
    162168                return array(
  • woocommerce-services/trunk/classes/class-wc-rest-connect-shipping-label-refund-controller.php

    r2498257 r3732427  
    1313
    1414        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
    1525                $response = $this->api_client->send_shipping_label_refund_request( $request['label_id'] );
    1626
    … …  
    3848                        'refund'   => $response->refund,
    3949                );
    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 );
    4151
    4252                return array(
  • woocommerce-services/trunk/classes/class-wc-rest-connect-shipping-label-status-controller.php

    r2510126 r3732427  
    1313
    1414        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
    1520                $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();
    1730                foreach ( $label_ids as $label_id ) {
    1831                        $response = $this->api_client->get_label_status( $label_id );
    … …  
    2740                        }
    2841
    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 );
    3043                        $labels[] = $label;
    3144                }
  • woocommerce-services/trunk/classes/class-wc-rest-connect-shipping-rates-controller.php

    r2498257 r3732427  
    3434
    3535        /**
     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        /**
    3664         *
    3765         * @param WP_REST_Request $request - See WC_Connect_API_Client::get_label_rates()
    … …  
    3967         */
    4068        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
    4286                $payload['payment_method_id'] = $this->settings_store->get_selected_payment_method_id();
    43                 $order_id                     = $request['order_id'];
     87                $order_id                     = $order->get_id();
    4488
    4589                // This is the earliest point in the printing label flow where we are sure that
    … …  
    5599                        }
    56100                        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
    59115                                        update_post_meta(
    60                                                 $item['product_id'],
     116                                                (int) $product_id,
    61117                                                'wc_connect_customs_info',
    62118                                                array(
  • woocommerce-services/trunk/classes/class-wc-rest-connect-tos-controller.php

    r2498257 r3732427  
    4242
    4343        /**
    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
    4549         */
    4650        public function check_permission( $request ) {
    4751                return current_user_can( 'manage_woocommerce' ) &&
    4852                        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();
    5056        }
    5157}
  • woocommerce-services/trunk/classes/wc-api-dev/class-wc-rest-dev-data-continents-controller.php

    r3034437 r3732427  
    7676                                        'args'                => array(
    7777                                                'continent' => array(
    78                                                         'description' => __( '2 character continent code.', 'woocommerce' ),
     78                                                        'description' => __( '2 character continent code.', 'woocommerce-services' ),
    7979                                                        'type'        => 'string',
    8080                                                ),
    … …  
    116116                $data = $this->continents->get_continent( strtoupper( $request['location'] ), $request );
    117117                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 ) );
    119119                }
    120120                return $this->prepare_item_for_response( $data, $request );
    … …  
    181181                                'code'      => array(
    182182                                        'type'        => 'string',
    183                                         'description' => __( '2 character continent code.', 'woocommerce' ),
     183                                        'description' => __( '2 character continent code.', 'woocommerce-services' ),
    184184                                        'context'     => array( 'view' ),
    185185                                        'readonly'    => true,
    … …  
    187187                                'name'      => array(
    188188                                        'type'        => 'string',
    189                                         'description' => __( 'Full name of continent.', 'woocommerce' ),
     189                                        'description' => __( 'Full name of continent.', 'woocommerce-services' ),
    190190                                        'context'     => array( 'view' ),
    191191                                        'readonly'    => true,
    … …  
    193193                                'countries' => array(
    194194                                        'type'        => 'array',
    195                                         'description' => __( 'List of countries on this continent.', 'woocommerce' ),
     195                                        'description' => __( 'List of countries on this continent.', 'woocommerce-services' ),
    196196                                        'context'     => array( 'view' ),
    197197                                        'readonly'    => true,
    … …  
    203203                                                        'code'           => array(
    204204                                                                'type'        => 'string',
    205                                                                 'description' => __( 'ISO3166 alpha-2 country code.', 'woocommerce' ),
     205                                                                'description' => __( 'ISO3166 alpha-2 country code.', 'woocommerce-services' ),
    206206                                                                'context'     => array( 'view' ),
    207207                                                                'readonly'    => true,
    … …  
    209209                                                        'currency_code'  => array(
    210210                                                                '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' ),
    212212                                                                'context'     => array( 'view' ),
    213213                                                                'readonly'    => true,
    … …  
    215215                                                        'currency_pos'   => array(
    216216                                                                'type'        => 'string',
    217                                                                 'description' => __( 'Currency symbol position for this country.', 'woocommerce' ),
     217                                                                'description' => __( 'Currency symbol position for this country.', 'woocommerce-services' ),
    218218                                                                'context'     => array( 'view' ),
    219219                                                                'readonly'    => true,
    … …  
    221221                                                        'decimal_sep'    => array(
    222222                                                                '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' ),
    224224                                                                'context'     => array( 'view' ),
    225225                                                                'readonly'    => true,
    … …  
    227227                                                        'dimension_unit' => array(
    228228                                                                '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' ),
    230230                                                                'context'     => array( 'view' ),
    231231                                                                'readonly'    => true,
    … …  
    233233                                                        'name'           => array(
    234234                                                                'type'        => 'string',
    235                                                                 'description' => __( 'Full name of country.', 'woocommerce' ),
     235                                                                'description' => __( 'Full name of country.', 'woocommerce-services' ),
    236236                                                                'context'     => array( 'view' ),
    237237                                                                'readonly'    => true,
    … …  
    239239                                                        'num_decimals'   => array(
    240240                                                                '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' ),
    242242                                                                'context'     => array( 'view' ),
    243243                                                                'readonly'    => true,
    … …  
    245245                                                        'states'         => array(
    246246                                                                'type'        => 'array',
    247                                                                 'description' => __( 'List of states in this country.', 'woocommerce' ),
     247                                                                'description' => __( 'List of states in this country.', 'woocommerce-services' ),
    248248                                                                'context'     => array( 'view' ),
    249249                                                                'readonly'    => true,
    … …  
    255255                                                                                'code' => array(
    256256                                                                                        'type'        => 'string',
    257                                                                                         'description' => __( 'State code.', 'woocommerce' ),
     257                                                                                        'description' => __( 'State code.', 'woocommerce-services' ),
    258258                                                                                        'context'     => array( 'view' ),
    259259                                                                                        'readonly'    => true,
    … …  
    261261                                                                                'name' => array(
    262262                                                                                        'type'        => 'string',
    263                                                                                         'description' => __( 'Full name of state.', 'woocommerce' ),
     263                                                                                        'description' => __( 'Full name of state.', 'woocommerce-services' ),
    264264                                                                                        'context'     => array( 'view' ),
    265265                                                                                        'readonly'    => true,
    … …  
    270270                                                        'thousand_sep'   => array(
    271271                                                                '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' ),
    273273                                                                'context'     => array( 'view' ),
    274274                                                                'readonly'    => true,
    … …  
    276276                                                        'weight_unit'    => array(
    277277                                                                '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' ),
    279279                                                                'context'     => array( 'view' ),
    280280                                                                'readonly'    => true,
  • woocommerce-services/trunk/classes/wc-api-dev/class-wc-rest-dev-data-controller.php

    r2498257 r3732427  
    6767        public function get_items_permissions_check( $request ) {
    6868                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() ) );
    7070                }
    7171
    … …  
    8181        public function get_item_permissions_check( $request ) {
    8282                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() ) );
    8484                }
    8585
    … …  
    9999                        array(
    100100                                'slug'        => 'continents',
    101                                 'description' => __( 'List of supported continents, countries, and states.', 'woocommerce' ),
     101                                'description' => __( 'List of supported continents, countries, and states.', 'woocommerce-services' ),
    102102                        ),
    103103                        array(
    104104                                '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' ),
    106106                        ),
    107107                        array(
    108108                                'slug'        => 'currencies',
    109                                 'description' => __( 'List of supported currencies.', 'woocommerce' ),
     109                                'description' => __( 'List of supported currencies.', 'woocommerce-services' ),
    110110                        ),
    111111                );
    … …  
    174174                        'properties' => array(
    175175                                'slug'        => array(
    176                                         'description' => __( 'Data resource ID.', 'woocommerce' ),
     176                                        'description' => __( 'Data resource ID.', 'woocommerce-services' ),
    177177                                        'type'        => 'string',
    178178                                        'context'     => array( 'view' ),
    … …  
    180180                                ),
    181181                                'description' => array(
    182                                         'description' => __( 'Data resource description.', 'woocommerce' ),
     182                                        'description' => __( 'Data resource description.', 'woocommerce-services' ),
    183183                                        'type'        => 'string',
    184184                                        'context'     => array( 'view' ),
  • woocommerce-services/trunk/i18n/languages/woocommerce-services.pot

    r3707890 r3732427  
    55"Project-Id-Version: _s woocommerce-services\n"
    66"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"
    88"MIME-Version: 1.0\n"
    99"Content-Type: text/plain; charset=UTF-8\n"
    … …  
    110110#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    111111#: dist/chunks/13f51058cf091ad1e827.min.js:1
    112 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     112#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    113113msgid "Create shipping label"
    114114msgid_plural "Create shipping labels"
    … …  
    117117
    118118#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    119 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     119#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    120120msgid "Track Package"
    121121msgid_plural "Track Packages"
    … …  
    124124
    125125#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    126 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     126#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    127127msgid "Create new label"
    128128msgstr ""
    129129
    130130#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    131 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     131#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    132132msgid "Connection error: unable to create label at this time"
    133133msgstr ""
    134134
    135135#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    136 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     136#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    137137msgid "%(itemCount)d item was fulfilled on {{span}}%(createdDate)s{{/span}}"
    138138msgid_plural ""
    … …  
    142142
    143143#: dist/chunks/0f0750e0dfa6ef74edc7.min.js:1
    144 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     144#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    145145msgid "%(itemCount)d item is ready to be fulfilled"
    146146msgid_plural "%(itemCount)d items are ready to be fulfilled"
    … …  
    804804#: dist/chunks/13f51058cf091ad1e827.min.js:1
    805805#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    806 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     806#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    807807msgid "Try WooCommerce Shipping"
    808808msgstr ""
    … …  
    810810#: dist/chunks/13f51058cf091ad1e827.min.js:1
    811811#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    812 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     812#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    813813msgid "A new WooCommerce Shipping experience is now available"
    814814msgstr ""
    … …  
    816816#: dist/chunks/13f51058cf091ad1e827.min.js:1
    817817#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    818 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     818#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    819819msgid ""
    820820"We'll ensure a seamless transition by allowing you to migrate all your "
    … …  
    824824#: dist/chunks/13f51058cf091ad1e827.min.js:1
    825825#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    826 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     826#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    827827msgid "Here's what you can expect from the new shipping experience:"
    828828msgstr ""
    … …  
    830830#: dist/chunks/13f51058cf091ad1e827.min.js:1
    831831#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    832 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     832#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    833833msgid "A seamless transition"
    834834msgstr ""
    … …  
    836836#: dist/chunks/13f51058cf091ad1e827.min.js:1
    837837#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    838 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     838#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    839839msgid ""
    840840"Automatically migrate all your compatible settings and shipment history to "
    … …  
    844844#: dist/chunks/13f51058cf091ad1e827.min.js:1
    845845#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    846 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     846#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    847847msgid "Print and save"
    848848msgstr ""
    … …  
    850850#: dist/chunks/13f51058cf091ad1e827.min.js:1
    851851#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    852 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     852#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    853853msgid ""
    854854"Speed up label creation with a streamlined process to print and save your "
    … …  
    858858#: dist/chunks/13f51058cf091ad1e827.min.js:1
    859859#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    860 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     860#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    861861msgid "USPS and DHL Express"
    862862msgstr ""
    … …  
    864864#: dist/chunks/13f51058cf091ad1e827.min.js:1
    865865#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    866 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     866#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    867867msgid ""
    868868"Ship your productss using trusted shipping carriers like USPS and DHL "
    … …  
    872872#: dist/chunks/13f51058cf091ad1e827.min.js:1
    873873#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    874 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     874#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    875875msgid "Enhanced label purchase flow"
    876876msgstr ""
    … …  
    878878#: dist/chunks/13f51058cf091ad1e827.min.js:1
    879879#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    880 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     880#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    881881msgid ""
    882882"Experience a smoother label purchasing process with our updated interface."
    … …  
    885885#: dist/chunks/13f51058cf091ad1e827.min.js:1
    886886#: dist/chunks/8d185b5886cb12db43a8.min.js:1
    887 #: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.0.js:1
     887#: dist/woocommerce-services-wcshipping-migration-admin-notice-3.7.1.js:1
    888888msgid "Maybe later"
    889889msgstr ""
  • woocommerce-services/trunk/readme.txt

    r3707890 r3732427  
    77Tested up to: 7.1
    88WC requires at least: 10.9
    9 WC tested up to: 11.1
    10 Stable tag: 3.7.0
     9WC tested up to: 11.2
     10Stable tag: 3.7.1
    1111License: GPLv2 or later
    1212License URI: http://www.gnu.org/licenses/gpl-2.0.html
    … …  
    7171== Changelog ==
    7272
     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
    7398= 3.7.0 - 2026-09-22 =
    7499* Fix   - Prevent a fatal error that stopped WooCommerce Tax from being deactivated while WooCommerce was inactive.
    75100* Tweak - Update the bundled JavaScript libraries.
    76101* 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.
    77103
    78104= 3.6.16 - 2026-09-16 =
  • woocommerce-services/trunk/src/Integrations/WooCommerceBlocksIntegration.php

    r3635005 r3732427  
    127127                        $script_url,
    128128                        $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.
    130130                        true
    131131                );
  • woocommerce-services/trunk/src/StoreApi/StoreApiExtensionController.php

    r3331570 r3732427  
    1111
    1212use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
    13 use Exception;
     13use Throwable;
    1414
    1515defined( 'ABSPATH' ) || exit;
    … …  
    9393                                )
    9494                        );
    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                        );
    97103                }
    98104        }
    … …  
    111117                                )
    112118                        );
    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                        );
    115127                }
    116128        }
  • woocommerce-services/trunk/src/StoreNotices/StoreNoticesController.php

    r3392766 r3732427  
    2121
    2222        /**
     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        /**
    2330         * Notifier instance.
    2431         *
    … …  
    3744                add_action( 'woocommerce_after_calculate_totals', array( $this, 'maybe_display_notices' ), 30 );
    3845                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' ) );
    3948        }
    4049
    … …  
    4756                }
    4857
     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
    4965                $this->notifier->print_notices();
    5066                $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;
    51116        }
    52117
  • woocommerce-services/trunk/src/StoreNotices/StoreNoticesNotifier.php

    r3331570 r3732427  
    132132         */
    133133        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
    134138                $notices = WC()->session->get( self::WC_SESSION_KEY, array() );
    135139
  • woocommerce-services/trunk/src/Tax/Address.php

    r3640567 r3732427  
    3838
    3939use WC_Customer;
     40use WC_Order;
    4041use WP_Error;
    4142
    … …  
    267268
    268269        /**
     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        /**
    269299         * Build from an admin-order `$_POST` payload.
    270300         *
  • woocommerce-services/trunk/src/Utils.php

    r3350055 r3732427  
    6868         */
    6969        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.
    7071                return sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ?? $default ) );
    7172        }
  • woocommerce-services/trunk/vendor/composer/autoload_classmap.php

    r3698279 r3732427  
    9494    'Automattic\\WCServices\\StoreNotices\\StoreNoticesNotifier' => $baseDir . '/src/StoreNotices/StoreNoticesNotifier.php',
    9595    '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',
    9698    'Automattic\\WCServices\\Utils' => $baseDir . '/src/Utils.php',
    9799    'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
  • woocommerce-services/trunk/vendor/composer/autoload_static.php

    r3707890 r3732427  
    119119        'Automattic\\WCServices\\StoreNotices\\StoreNoticesNotifier' => __DIR__ . '/../..' . '/src/StoreNotices/StoreNoticesNotifier.php',
    120120        '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',
    121123        'Automattic\\WCServices\\Utils' => __DIR__ . '/../..' . '/src/Utils.php',
    122124        'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
  • woocommerce-services/trunk/vendor/composer/installed.php

    r3707890 r3732427  
    44        'pretty_version' => 'dev-trunk',
    55        'version' => 'dev-trunk',
    6         'reference' => '52b37612e215ad51a541900cf94eca91c8691fee',
     6        'reference' => 'd726c3d7abe4c8fa65a77d8cde0a81087380213a',
    77        'type' => 'library',
    88        'install_path' => __DIR__ . '/../../',
    … …  
    1414            'pretty_version' => 'dev-trunk',
    1515            'version' => 'dev-trunk',
    16             'reference' => '52b37612e215ad51a541900cf94eca91c8691fee',
     16            'reference' => 'd726c3d7abe4c8fa65a77d8cde0a81087380213a',
    1717            'type' => 'library',
    1818            'install_path' => __DIR__ . '/../../',
  • woocommerce-services/trunk/vendor/composer/jetpack_autoload_classmap.php

    r3698279 r3732427  
    367367                'path'    => $baseDir . '/src/Tax/Address.php'
    368368        ),
     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        ),
    369377        'Automattic\\WCServices\\Utils' => array(
    370378                'version' => 'dev-trunk',
  • woocommerce-services/trunk/woocommerce-services.php

    r3707890 r3732427  
    99 * Text Domain: woocommerce-services
    1010 * 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
    1214 * Requires Plugins: woocommerce
    1315 * Requires PHP: 7.4
    … …  
    1517 * Tested up to: 7.1
    1618 * WC requires at least: 10.9
    17  * WC tested up to: 11.1
     19 * WC tested up to: 11.2
    1820 *
    1921 * Copyright (c) 2017-2023 Automattic
    … …  
    9496                 */
    9597                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;
    96105
    97106                /**
    … …  
    13851394                 * Note: intended to be hooked into 'rest_request_before_callbacks'
    13861395                 *
    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.
    13901405                 *
    13911406                 * @return mixed - pass through value of $response.
    13921407                 */
    13931408                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 ) {
    13951410                                return $response;
    13961411                        }
    13971412
    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() ) );
    14031426                        }
    14041427
    14051428                        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 ? '...' : '' );
    14061489                }
    14071490
    … …  
    17831866
    17841867                        // 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.
    17851869                        if ( isset( $_GET['action'] ) ) {
    17861870                                return false;
    … …  
    18391923                        }
    18401924
    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.
    18441928
    18451929                        $i18n_json = $this->get_i18n_json();
    … …  
    18691953
    18701954                        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.
    18711956                                $is_active = $wpdb->get_var(
    18721957                                        $wpdb->prepare(
    … …  
    20712156                        $meta_table_to_check = OrderUtil::get_table_for_order_meta();
    20722157
     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.
    20732159                        return (bool) $wpdb->get_var(
    20742160                                $wpdb->prepare(
    … …  
    21682254                                'https://cdn.sift.com/s.js',
    21692255                                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.
    21712257                                array(
    21722258                                        'strategy'  => 'defer',
    … …  
    22522338                        $encoded_arguments = wp_json_encode( $extra_args );
    22532339                        ?>
    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. ?>">
    22552341                                        <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                                                ?>
    22572346                                        </span>
    22582347                                </div>
    … …  
    23892478                                sprintf(
    23902479                                        /* 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.
    23922481                                        'https://woocommerce.com/document/woocommerce-shipping-and-tax/woocommerce-shipping/#how-do-i-migrate-from-wcst'
    23932482                                ) .
    … …  
    24072496                        $plugin_version = self::get_wcs_version();
    24082497                        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.
    24102499                        wp_localize_script(
    24112500                                'wcst_wcshipping_migration_admin_notice',
Note: See TracChangeset for help on using the changeset viewer.