Commit 6ab74ad24ca for woocommerce

commit 6ab74ad24ca99319c146e5052ab20ee485d367e0
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Wed Nov 4 14:00:57 2026 +0100

    Fix PayPal Standard shipping and tax updates made in PayPal (#70634)

    When the shopper changes the address or shipping method in PayPal, update only the shipping items and recalculate shipping and tax from the order instead of rebuilding it from the cart.

    Co-authored-by: Dale du Preez <3376401+daledupreez@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-paypal-standard-shipping-order-updates b/plugins/woocommerce/changelog/fix-paypal-standard-shipping-order-updates
new file mode 100644
index 00000000000..e8e00b87de5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-paypal-standard-shipping-order-updates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Ensure PayPal Standard shipping updates correctly update shipping and tax amounts
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php
index 4c8b209b96d..f8e5a715670 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php
@@ -13,6 +13,8 @@ declare(strict_types=1);

 defined( 'ABSPATH' ) || exit;

+use Automattic\WooCommerce\Enums\ProductTaxStatus;
+use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Gateways\PayPal\Helper as PayPalHelper;
 use Automattic\WooCommerce\Gateways\PayPal\Request as PayPalRequest;
 use Automattic\WooCommerce\Gateways\PayPal\Constants as PayPalConstants;
@@ -126,6 +128,10 @@ class WC_REST_Paypal_Standard_Controller extends WC_REST_Controller {
 			return new WP_REST_Response( $response, 422 );
 		}

+		if ( ! is_array( $shipping_option ) ) {
+			$shipping_option = array();
+		}
+
 		// Get the WC order.
 		$order = PayPalHelper::get_wc_order_from_paypal_custom_id( $purchase_units[0]['custom_id'] ?? '{}' );
 		if ( ! $order ) {
@@ -177,70 +183,130 @@ class WC_REST_Paypal_Standard_Controller extends WC_REST_Controller {
 		// Update the shipping address before we do anything else.
 		$this->update_order_shipping_address( $order, $shipping_address );

-		// We need to rebuild the cart from the order, as we do not have session cart data
-		// for REST API requests.
-		$this->rebuild_cart_from_order( $order );
+		$re_add_cart_item_fees = static function ( $cart ) use ( $order ) {
+			if ( ! ( $cart instanceof WC_Cart ) ) {
+				return;
+			}

-		// Get the new shipping options, which depend on the new shipping address.
-		$updated_shipping_options = $this->get_updated_shipping_options( $order, $shipping_option );
-		if ( empty( $updated_shipping_options ) ) {
-			WC_Gateway_Paypal::log(
-				'No shipping options found for address. Order ID: ' . $order->get_id() .
-				'. Address: ' . wp_json_encode( $shipping_address )
-			);
-			$response = $this->get_update_shipping_error_response();
-			return new WP_REST_Response( $response, 422 );
-		}
+			foreach ( $order->get_fees() as $fee_item ) {
+				if ( ! ( $fee_item instanceof WC_Order_Item_Fee ) ) {
+					continue;
+				}
+				WC()->cart->add_fee(
+					$fee_item->get_name(),
+					(float) $fee_item->get_amount(),
+					ProductTaxStatus::TAXABLE === $fee_item->get_tax_status(),
+					$fee_item->get_tax_class()
+				);
+			}
+		};
+		add_action( 'woocommerce_cart_calculate_fees', $re_add_cart_item_fees );
+
+		try {
+			// We need to rebuild the cart from the order, as we do not have session cart data
+			// for REST API requests.
+			try {
+				$this->rebuild_cart_from_order( $order );
+			} catch ( Exception $e ) {
+				WC_Gateway_Paypal::log(
+					'Error rebuilding cart from order. Order ID: ' . $order->get_id() .
+					'. Error: ' . $e->getMessage()
+				);
+				$response = $this->get_update_shipping_error_response();
+				return new WP_REST_Response( $response, 422 );
+			}

-		// Set the chosen shipping method in the session.
-		if ( ! empty( $shipping_option ) ) {
-			WC()->session->set( 'chosen_shipping_methods', array( $shipping_option['id'] ) );
-		}
+			// Get the new shipping options, which depend on the new shipping address.
+			$updated_shipping_options = $this->get_updated_shipping_options( $order, $shipping_option, $updated_shipping_option_id );
+			if ( empty( $updated_shipping_options ) ) {
+				WC_Gateway_Paypal::log(
+					'No shipping options found for address. Order ID: ' . $order->get_id() .
+					'. Address: ' . wp_json_encode( $shipping_address )
+				);
+				$response = $this->get_update_shipping_error_response();
+				return new WP_REST_Response( $response, 422 );
+			}

-		// Recompute fees after everything has been updated.
-		$this->recompute_fees( $order );
+			if ( null === $updated_shipping_option_id ) {
+				WC_Gateway_Paypal::log(
+					'No default shipping option selected after address update. Order ID: ' . $order->get_id() .
+					'. Shipping options: ' . wp_json_encode( $updated_shipping_options )
+				);
+				$response = $this->get_update_shipping_error_response();
+				return new WP_REST_Response( $response, 422 );
+			}

-		$paypal_request = new PayPalRequest( WC_Gateway_Paypal::get_instance() );
-		$updated_amount = $paypal_request->get_paypal_order_purchase_unit_amount( $order );
+			// Update the shipping method for the order and recompute the shipping and tax
+			// totals based on the address and shipping method updates.
+			$this->update_shipping_method_and_totals_for_order( $order, $updated_shipping_option_id );

-		$response = array(
-			'id'             => $paypal_order_id,
-			'purchase_units' => array(
-				array(
-					'reference_id'     => isset( $purchase_units[0]['reference_id'] ) ? $purchase_units[0]['reference_id'] : '', // No change.
-					'amount'           => $updated_amount,
-					'shipping_options' => $updated_shipping_options,
+			$paypal_request = new PayPalRequest( WC_Gateway_Paypal::get_instance() );
+			$updated_amount = $paypal_request->get_paypal_order_purchase_unit_amount( $order );
+
+			$response = array(
+				'id'             => $paypal_order_id,
+				'purchase_units' => array(
+					array(
+						'reference_id'     => isset( $purchase_units[0]['reference_id'] ) ? $purchase_units[0]['reference_id'] : '', // No change.
+						'amount'           => $updated_amount,
+						'shipping_options' => $updated_shipping_options,
+					),
 				),
-			),
-		);
+			);

-		return new WP_REST_Response( $response, 200 );
+			return new WP_REST_Response( $response, 200 );
+		} finally {
+			remove_action( 'woocommerce_cart_calculate_fees', $re_add_cart_item_fees );
+		}
 	}

 	/**
 	 * Rebuild the session cart.
 	 *
 	 * @param WC_Order $order The order object.
-	 * @return void
+	 * @throws Exception If the cart cannot be rebuilt.
 	 */
-	private function rebuild_cart_from_order( $order ) {
+	private function rebuild_cart_from_order( $order ): void {
 		wc_load_cart();
 		WC()->cart->empty_cart();
 		foreach ( $order->get_items() as $item ) {
+			if ( ! ( $item instanceof WC_Order_Item_Product ) ) {
+				continue;
+			}
+
 			$product_id = $item->get_product_id();
 			$product    = $item->get_product();

-			if ( ! $product ) {
+			if ( ! $product instanceof WC_Product ) {
 				continue;
 			}

-			if ( $product->is_type( 'variation' ) ) {
-				$variation_id = $item->get_variation_id();
-				WC()->cart->add_to_cart( $product_id, $item->get_quantity(), $variation_id );
-				continue;
+			$order_product_subtotal = (float) $item->get_subtotal();
+			$order_product_quantity = (float) $item->get_quantity();
+			$order_product_price    = round( $order_product_subtotal / $order_product_quantity, wc_get_price_decimals() );
+
+			if ( $product->is_type( ProductType::VARIATION ) ) {
+				$variation_id  = $item->get_variation_id();
+				$cart_item_key = WC()->cart->add_to_cart(
+					$product_id,
+					$item->get_quantity(),
+					$variation_id,
+					$this->get_variation_attributes_from_order_item( $item, $product_id )
+				);
+			} else {
+				$cart_item_key = WC()->cart->add_to_cart( $product_id, $item->get_quantity() );
+			}
+
+			if ( ! is_string( $cart_item_key ) ) {
+				throw new Exception( 'Failed to add product to cart.' );
 			}

-			WC()->cart->add_to_cart( $product_id, $item->get_quantity() );
+			$cart_item = WC()->cart->get_cart_item( $cart_item_key );
+			if ( ! $cart_item ) {
+				throw new Exception( 'Failed to get cart item.' );
+			}
+
+			$cart_item['data']->set_price( (string) $order_product_price );
 		}

 		// Re-apply coupons present on the order so discounts/totals are accurate.
@@ -252,6 +318,10 @@ class WC_REST_Paypal_Standard_Controller extends WC_REST_Controller {
 			}
 		}

+		// Recalculate the cart totals after all changes have been applied.
+		// Note that fees are handled via a hook.
+		WC()->cart->calculate_totals();
+
 		// Re-apply shipping methods present on the order so totals are accurate.
 		$order_shipping_rate_id = $this->get_order_shipping_rate_id( $order );
 		if ( ! empty( $order_shipping_rate_id ) ) {
@@ -260,19 +330,74 @@ class WC_REST_Paypal_Standard_Controller extends WC_REST_Controller {
 	}

 	/**
-	 * Recompute the fees for the order.
+	 * Get the variation attributes stored in an order item's meta, in the format expected by
+	 * WC_Cart::add_to_cart(). This mirrors the attribute handling in {@see WC_Cart_Session::populate_cart_from_order()}.
 	 *
-	 * @param WC_Order $order The order object.
-	 * @return void
+	 * @param WC_Order_Item_Product $item       The order item.
+	 * @param int                   $product_id The ID of the parent variable product.
+	 * @return array Attribute values keyed by 'attribute_{name}'.
 	 */
-	private function recompute_fees( $order ) {
-		WC()->cart->calculate_fees();
-		WC()->cart->calculate_shipping();
-		WC()->cart->calculate_totals();
+	private function get_variation_attributes_from_order_item( WC_Order_Item_Product $item, int $product_id ): array {
+		$variation_attributes = array();

-		$order->remove_order_items();
-		WC()->checkout->set_data_from_cart( $order );
-		$order->save();
+		foreach ( $item->get_meta_data() as $meta ) {
+			$is_taxonomy_attribute = taxonomy_is_product_attribute( $meta->key );
+			if ( ! $is_taxonomy_attribute && ! meta_is_product_attribute( $meta->key, $meta->value, $product_id ) ) {
+				continue;
+			}
+
+			$attribute_key = 'attribute_' . sanitize_title( $meta->key );
+			if ( $is_taxonomy_attribute ) {
+				$variation_attributes[ $attribute_key ] = sanitize_title( $meta->value );
+			} else {
+				$meta_value   = wc_clean( $meta->value );
+				$blog_charset = get_bloginfo( 'charset' );
+				if ( is_string( $meta_value ) ) {
+					$variation_attributes[ $attribute_key ] = html_entity_decode( $meta_value, ENT_QUOTES, $blog_charset );
+				} elseif ( is_array( $meta_value ) ) {
+					$variation_attributes[ $attribute_key ] = $this->decode_meta_values( $meta->value, $blog_charset );
+				}
+			}
+		}
+
+		return $variation_attributes;
+	}
+
+	/**
+	 * Helper method to support recursively ensuring metadata string values are decoded.
+	 *
+	 * @param array  $meta_value_array The metadata value array.
+	 * @param string $blog_charset     The blog charset.
+	 * @return array The decoded metadata value array.
+	 */
+	private function decode_meta_values( array $meta_value_array, string $blog_charset ): array {
+		$decoded_values = array();
+		foreach ( $meta_value_array as $key => $value ) {
+			if ( is_string( $value ) ) {
+				$decoded_values[ $key ] = html_entity_decode( $value, ENT_QUOTES, $blog_charset );
+			} elseif ( is_array( $value ) ) {
+				$decoded_values[ $key ] = $this->decode_meta_values( $value, $blog_charset );
+			} elseif ( is_scalar( $value ) ) {
+				$decoded_values[ $key ] = $value;
+			}
+		}
+		return $decoded_values;
+	}
+
+	/**
+	 * Update the shipping method for the order, and then recompute the order totals.
+	 *
+	 * @param WC_Order $order              The order object.
+	 * @param string   $shipping_option_id The shipping option id.
+	 * @return void
+	 */
+	private function update_shipping_method_and_totals_for_order( $order, $shipping_option_id ): void {
+		// Remove and recreate the shipping lines for the order.
+		$order->remove_order_items( 'shipping' );
+		$packages = WC()->shipping()->get_packages();
+		WC()->checkout()->create_order_shipping_lines( $order, array( $shipping_option_id ), $packages );
+		// Update the order totals based on the updated shipping method and any relevant taxes.
+		$order->calculate_totals( true );
 	}

 	/**
@@ -311,12 +436,16 @@ class WC_REST_Paypal_Standard_Controller extends WC_REST_Controller {
 	/**
 	 * Get the shipping options for the order.
 	 *
-	 * @param WC_Order $order The order object.
-	 * @param array    $selected_shipping_option The selected shipping option.
+	 * @param WC_Order    $order                      The order object.
+	 * @param array       $selected_shipping_option   The selected shipping option.
+	 * @param string|null $updated_shipping_option_id The updated shipping option id after the updates.
 	 * @return array The shipping options.
 	 */
-	private function get_updated_shipping_options( $order, $selected_shipping_option ) {
+	private function get_updated_shipping_options( $order, $selected_shipping_option, ?string &$updated_shipping_option_id ) {
+		$updated_shipping_option_id = null;
+
 		WC()->cart->calculate_shipping();
+
 		$packages               = WC()->shipping()->get_packages();
 		$order_shipping_rate_id = $this->get_order_shipping_rate_id( $order );

@@ -340,6 +469,7 @@ class WC_REST_Paypal_Standard_Controller extends WC_REST_Controller {

 				if ( $is_selected ) {
 					$has_selected_shipping_option = true;
+					$updated_shipping_option_id   = $shipping_option_id;
 				}
 				$options[] = array(
 					'id'       => $shipping_option_id,
@@ -356,7 +486,8 @@ class WC_REST_Paypal_Standard_Controller extends WC_REST_Controller {

 		// Set first option as selected if no option is selected.
 		if ( ! empty( $options ) && ! $has_selected_shipping_option ) {
-			$options[0]['selected'] = true;
+			$options[0]['selected']     = true;
+			$updated_shipping_option_id = $options[0]['id'];
 		}

 		return $options;
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 2190e984573..40a0333d191 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -28038,12 +28038,6 @@ parameters:
 			count: 1
 			path: includes/rest-api/Controllers/Version3/class-wc-rest-paypal-buttons-controller.php

-		-
-			message: '#^Access to an undefined property WooCommerce\:\:\$checkout\.$#'
-			identifier: property.notFound
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php
-
 		-
 			message: '#^Call to an undefined method WC_Order_Item\:\:get_instance_id\(\)\.$#'
 			identifier: method.notFound
@@ -28056,24 +28050,6 @@ parameters:
 			count: 1
 			path: includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php

-		-
-			message: '#^Call to an undefined method WC_Order_Item\:\:get_product\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php
-
-		-
-			message: '#^Call to an undefined method WC_Order_Item\:\:get_product_id\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php
-
-		-
-			message: '#^Call to an undefined method WC_Order_Item\:\:get_variation_id\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller.php
-
 		-
 			message: '#^Method WC_REST_Paypal_Standard_Controller\:\:process_shipping_callback\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
 			identifier: missingType.generics
@@ -33987,7 +33963,6 @@ parameters:
 			count: 2
 			path: includes/wc-order-functions.php

-
 		-
 			message: '#^Call to an undefined method WC_Data_Store\:\:add_metadata\(\)\.$#'
 			identifier: method.notFound
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller-test.php
new file mode 100644
index 00000000000..ea599b85a85
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-standard-controller-test.php
@@ -0,0 +1,700 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+
+/**
+ * Tests for the POST /wc/v3/paypal-standard/update-shipping endpoint
+ * implemented in {@see WC_REST_Paypal_Standard_Controller}.
+ *
+ * @group paypal
+ */
+class WC_REST_Paypal_Standard_Controller_Test extends WC_REST_Unit_Test_Case {
+
+	private const PAYPAL_ORDER_ID = 'PAYPAL-ORDER-123';
+
+	private const TEST_ORDER_ITEM_META_KEY = 'test_custom_meta';
+
+	/**
+	 * Rate id of the US "Standard" flat rate (cost 10).
+	 *
+	 * @var string
+	 */
+	private $us_standard_rate_id;
+
+	/**
+	 * Rate id of the US "Express" flat rate (cost 25).
+	 *
+	 * @var string
+	 */
+	private $us_express_rate_id;
+
+	/**
+	 * Rate id of the Canada flat rate (cost 5).
+	 *
+	 * @var string
+	 */
+	private $ca_rate_id;
+
+	/**
+	 * The order under test.
+	 *
+	 * @var WC_Order
+	 */
+	private $order;
+
+	/**
+	 * Customer before the test; the endpoint replaces WC()->customer.
+	 *
+	 * @var WC_Customer|null
+	 */
+	private $previous_customer;
+
+	/**
+	 * Session before the test; the endpoint may replace WC()->session.
+	 *
+	 * @var WC_Session|null
+	 */
+	private $previous_session;
+
+	/**
+	 * The ID of the product order item meta.
+	 *
+	 * @var int|null
+	 */
+	private $product_order_item_meta_id = null;
+
+	/**
+	 * Set up shipping zones and a pending PayPal order.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->previous_customer = WC()->customer;
+		$this->previous_session  = WC()->session;
+
+		$this->product_order_item_meta_id = null;
+
+		add_filter( 'woocommerce_shipping_legacy_flat_rate_is_available', array( $this, 'disable_legacy_flat_rate_shipping' ), 99999 );
+
+		update_option( 'woocommerce_shipping_tax_class', 'inherit' );
+
+		// Remove all existing shipping zones.
+		foreach ( WC_Shipping_Zones::get_zones() as $zone ) {
+			WC_Shipping_Zones::delete_zone( $zone->get_id() );
+		}
+
+		$us_zone = new WC_Shipping_Zone();
+		$us_zone->set_zone_name( 'US' );
+		$us_zone->add_location( 'US', 'country' );
+		$us_zone->save();
+		$this->us_standard_rate_id = $this->add_flat_rate( $us_zone, 'Standard', '10' );
+		$this->us_express_rate_id  = $this->add_flat_rate( $us_zone, 'Express', '25' );
+
+		$ca_zone = new WC_Shipping_Zone();
+		$ca_zone->set_zone_name( 'Canada' );
+		$ca_zone->add_location( 'CA', 'country' );
+		$ca_zone->save();
+		$this->ca_rate_id = $this->add_flat_rate( $ca_zone, 'Canada flat rate', '5' );
+
+		WC_Cache_Helper::get_transient_version( 'shipping', true );
+
+		$product_order_item_id = null;
+		$this->order           = $this->create_paypal_order( $this->us_standard_rate_id, self::PAYPAL_ORDER_ID, $product_order_item_id );
+
+		$this->product_order_item_meta_id = $product_order_item_id;
+	}
+
+	/**
+	 * Restore the customer and session replaced by the endpoint.
+	 */
+	public function tearDown(): void {
+		WC()->customer = $this->previous_customer;
+		WC()->session  = $this->previous_session;
+
+		remove_filter( 'woocommerce_shipping_legacy_flat_rate_is_available', array( $this, 'disable_legacy_flat_rate_shipping' ), 99999 );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Disable legacy flat rate shipping.
+	 *
+	 * @param bool $available Whether legacy flat rate shipping is available.
+	 * @return false Always disable legacy flat rate shipping.
+	 */
+	public function disable_legacy_flat_rate_shipping( $available ): bool {
+		return false;
+	}
+
+	/**
+	 * @testdox Should apply the shipping option sent in the request to the order and recompute totals.
+	 */
+	public function test_applies_requested_shipping_option_to_order(): void {
+		$random_meta   = $this->add_test_order_item_meta( $this->order, $this->product_order_item_meta_id, self::TEST_ORDER_ITEM_META_KEY );
+		$line_item_ids = array_keys( $this->order->get_items() );
+
+		$response = $this->dispatch_update_shipping(
+			$this->get_us_address(),
+			array( 'id' => $this->us_express_rate_id )
+		);
+
+		$this->assertSame( 200, $response->get_status() );
+
+		$order          = wc_get_order( $this->order->get_id() );
+		$shipping_items = $order->get_items( 'shipping' );
+		$this->assertCount( 1, $shipping_items, 'The order should have exactly one shipping line' );
+		$this->assertSame( $this->us_express_rate_id, $this->get_rate_id( current( $shipping_items ) ) );
+		$this->assertSame( 25.0, (float) $order->get_shipping_total() );
+		$this->assertSame( 75.0, (float) $order->get_total() );
+		$this->assertSame( $line_item_ids, array_keys( $order->get_items() ), 'Product line items should not be recreated' );
+
+		$data = $response->get_data();
+		$this->assertSame( '75.00', $data['purchase_units'][0]['amount']['value'] );
+		$this->assertSame( '25.00', $data['purchase_units'][0]['amount']['breakdown']['shipping']['value'] );
+
+		$this->assertSame(
+			array(
+				$this->us_standard_rate_id => false,
+				$this->us_express_rate_id  => true,
+			),
+			array_column( $data['purchase_units'][0]['shipping_options'], 'selected', 'id' )
+		);
+
+		$product_order_item = $this->get_product_order_item( $order, $this->product_order_item_meta_id );
+		$this->assertSame( $random_meta, $product_order_item->get_meta( self::TEST_ORDER_ITEM_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should keep the order's shipping method when the request has no valid shipping option.
+	 * @testWith [null]
+	 *           ["not-an-array"]
+	 *           [[]]
+	 *
+	 * @param mixed $shipping_option The shipping_option request param, omitted when null.
+	 */
+	public function test_keeps_order_shipping_method_without_valid_requested_option( $shipping_option ): void {
+		$random_meta = $this->add_test_order_item_meta( $this->order, $this->product_order_item_meta_id, self::TEST_ORDER_ITEM_META_KEY );
+
+		$response = $this->dispatch_update_shipping( $this->get_us_address(), $shipping_option );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		$order          = wc_get_order( $this->order->get_id() );
+		$shipping_items = $order->get_items( 'shipping' );
+		$this->assertCount( 1, $shipping_items, 'The order should have exactly one shipping line' );
+		$this->assertSame( $this->us_standard_rate_id, $this->get_rate_id( current( $shipping_items ) ) );
+		$this->assertSame( 60.0, (float) $order->get_total() );
+
+		$data = $response->get_data();
+		$this->assertSame(
+			array(
+				$this->us_standard_rate_id => true,
+				$this->us_express_rate_id  => false,
+			),
+			array_column( $data['purchase_units'][0]['shipping_options'], 'selected', 'id' )
+		);
+
+		$product_order_item = $this->get_product_order_item( $order, $this->product_order_item_meta_id );
+		$this->assertSame( $random_meta, $product_order_item->get_meta( self::TEST_ORDER_ITEM_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should apply the first available shipping option when the order's method is unavailable at the new address.
+	 */
+	public function test_applies_first_option_when_order_method_unavailable_for_new_address(): void {
+		$random_meta = $this->add_test_order_item_meta( $this->order, $this->product_order_item_meta_id, self::TEST_ORDER_ITEM_META_KEY );
+
+		$response = $this->dispatch_update_shipping( $this->get_canada_address(), null );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		$order          = wc_get_order( $this->order->get_id() );
+		$shipping_items = $order->get_items( 'shipping' );
+		$this->assertCount( 1, $shipping_items, 'The order should have exactly one shipping line' );
+		$this->assertSame( $this->ca_rate_id, $this->get_rate_id( current( $shipping_items ) ) );
+		$this->assertSame( 'CA', $order->get_shipping_country() );
+		$this->assertSame( 55.0, (float) $order->get_total() );
+
+		$data = $response->get_data();
+		$this->assertSame(
+			array( $this->ca_rate_id => true ),
+			array_column( $data['purchase_units'][0]['shipping_options'], 'selected', 'id' )
+		);
+
+		$product_order_item = $this->get_product_order_item( $order, $this->product_order_item_meta_id );
+		$this->assertSame( $random_meta, $product_order_item->get_meta( self::TEST_ORDER_ITEM_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should recalculate taxes based on the updated shipping address.
+	 */
+	public function test_recalculates_taxes_for_new_address(): void {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'CA',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'GST',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$random_meta = $this->add_test_order_item_meta( $this->order, $this->product_order_item_meta_id, self::TEST_ORDER_ITEM_META_KEY );
+
+		$response = $this->dispatch_update_shipping( $this->get_canada_address(), null );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		$order = wc_get_order( $this->order->get_id() );
+		$this->assertSame( 5.5, (float) $order->get_total_tax(), 'Tax should apply to both the item and the shipping line' );
+		$this->assertSame( 60.5, (float) $order->get_total() );
+
+		$data = $response->get_data();
+		$this->assertSame( '5.50', $data['purchase_units'][0]['amount']['breakdown']['tax_total']['value'] );
+		$this->assertSame( '60.50', $data['purchase_units'][0]['amount']['value'] );
+
+		$product_order_item = $this->get_product_order_item( $order, $this->product_order_item_meta_id );
+		$this->assertSame( $random_meta, $product_order_item->get_meta( self::TEST_ORDER_ITEM_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Should preserve an existing custom product price when updating the address and shipping method.
+	 */
+	public function test_existing_custom_product_price_is_preserved(): void {
+		$order_line_item = $this->get_product_order_item( $this->order, $this->product_order_item_meta_id );
+		$order_line_item->set_subtotal( '40.00' );
+		$order_line_item->set_total( '40.00' );
+		$order_line_item->save();
+
+		// Reload order to get the updated line item details.
+		$this->order = wc_get_order( $this->order->get_id() );
+		$this->order->calculate_totals( true );
+		$this->order->save();
+
+		$initial_order_total = $this->order->get_total();
+
+		$response = $this->dispatch_update_shipping( $this->get_us_address(), $this->us_standard_rate_id );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		$order                   = wc_get_order( $this->order->get_id() );
+		$updated_order_line_item = $this->get_product_order_item( $order, $this->product_order_item_meta_id );
+		$this->assertSame( '40.00', $updated_order_line_item->get_total() );
+		$this->assertSame( $initial_order_total, $order->get_total() );
+	}
+
+	/**
+	 * @testdox Should preserve fee lines on the order when updating shipping.
+	 */
+	public function test_preserves_fee_lines(): void {
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_name( 'Handling' );
+		$fee->set_total( '3' );
+		$this->order->add_item( $fee );
+		$this->order->calculate_totals( false );
+		$this->order->save();
+
+		$response = $this->dispatch_update_shipping(
+			$this->get_us_address(),
+			array( 'id' => $this->us_express_rate_id )
+		);
+
+		$this->assertSame( 200, $response->get_status() );
+
+		$order = wc_get_order( $this->order->get_id() );
+		$fees  = $order->get_items( 'fee' );
+		$this->assertCount( 1, $fees, 'The fee line should be kept' );
+		$this->assertSame( 'Handling', current( $fees )->get_name() );
+		$this->assertSame( 78.0, (float) $order->get_total() );
+	}
+
+	/**
+	 * @testdox Should rebuild the cart with the order's items, variation attributes, prices, fees, and coupons.
+	 */
+	public function test_rebuilt_cart_matches_order(): void {
+		$simple_product = WC_Helper_Product::create_simple_product();
+		$simple_product->set_regular_price( '50' );
+		$simple_product->save();
+
+		$variation = $this->get_any_number_variation();
+
+		WC_Helper_Coupon::create_coupon(
+			'paypal-five-off',
+			array(
+				'discount_type' => 'fixed_cart',
+				'coupon_amount' => '5',
+			)
+		);
+
+		$order             = wc_create_order();
+		$simple_item_id    = $this->add_product_line( $order, $simple_product, 2, 30.0 );
+		$variation_item_id = $this->add_product_line(
+			$order,
+			$variation,
+			1,
+			12.0,
+			array(
+				'pa_size'   => 'huge',
+				'pa_colour' => 'blue',
+				'pa_number' => '1',
+			)
+		);
+		$fee               = new WC_Order_Item_Fee();
+		$fee->set_name( 'Handling' );
+		$fee->set_amount( '3' );
+		$fee->set_total( '3' );
+		$order->add_item( $fee );
+		$order->apply_coupon( 'paypal-five-off' );
+		$this->finalize_paypal_order( $order, $this->us_standard_rate_id, self::PAYPAL_ORDER_ID );
+		$this->order = $order;
+
+		$response = $this->dispatch_update_shipping( $this->get_us_address(), array( 'id' => $this->us_standard_rate_id ) );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		$cart_items = array_values( WC()->cart->get_cart() );
+		$this->assertCount( 2, $cart_items, 'The cart should contain one entry per order line item' );
+
+		$simple_cart_item = $cart_items[0];
+		$this->assertSame( $simple_product->get_id(), $simple_cart_item['product_id'] );
+		$this->assertSame( 0, $simple_cart_item['variation_id'] );
+		$this->assertSame( 2, $simple_cart_item['quantity'] );
+		$this->assertSame( 30.0, (float) $simple_cart_item['data']->get_price(), 'The cart price should match the order price' );
+
+		$variation_cart_item = $cart_items[1];
+		$this->assertSame( $variation->get_parent_id(), $variation_cart_item['product_id'] );
+		$this->assertSame( $variation->get_id(), $variation_cart_item['variation_id'] );
+		$this->assertSame( 1, $variation_cart_item['quantity'] );
+		$this->assertSame( 12.0, (float) $variation_cart_item['data']->get_price(), 'The cart price should match the order price' );
+		$this->assertSame(
+			array(
+				'attribute_pa_size'   => 'huge',
+				'attribute_pa_colour' => 'blue',
+				'attribute_pa_number' => '1',
+			),
+			$variation_cart_item['variation'],
+			'The cart item should include the variation attributes from the order item, including the "Any" attribute value'
+		);
+
+		$this->assertSame( array( 'paypal-five-off' ), WC()->cart->get_applied_coupons() );
+
+		$cart_fees = array_values( WC()->cart->get_fees() );
+		$this->assertCount( 1, $cart_fees, 'The cart should contain the order fee' );
+		$this->assertSame( 'Handling', $cart_fees[0]->name );
+		$this->assertSame( 3.0, (float) $cart_fees[0]->amount );
+
+		$this->assertSame( 72.0, (float) WC()->cart->get_subtotal() );
+		$this->assertSame( 5.0, (float) WC()->cart->get_discount_total() );
+
+		$updated_order = wc_get_order( $order->get_id() );
+		$this->assertSame( array( $simple_item_id, $variation_item_id ), array_keys( $updated_order->get_items() ), 'Product line items should not be recreated' );
+		$this->assertSame( 80.0, (float) $updated_order->get_total(), 'Order total should be 72 - 5 + 3 fee + 10 shipping' );
+	}
+
+	/**
+	 * @testdox Should offer Free Shipping when the order total meets the minimum even though catalog prices do not.
+	 */
+	public function test_free_shipping_minimum_uses_order_prices(): void {
+		$us_zone = WC_Shipping_Zones::get_zone_matching_package(
+			array(
+				'destination' => array(
+					'country'  => 'US',
+					'state'    => '',
+					'postcode' => '',
+				),
+			)
+		);
+
+		$free_shipping_instance_id = $us_zone->add_shipping_method( 'free_shipping' );
+		update_option(
+			"woocommerce_free_shipping_{$free_shipping_instance_id}_settings",
+			array(
+				'title'            => 'Free shipping',
+				'requires'         => 'min_amount',
+				'min_amount'       => '100',
+				'ignore_discounts' => 'no',
+			)
+		);
+		$free_shipping_rate_id = "free_shipping:{$free_shipping_instance_id}";
+		WC_Cache_Helper::get_transient_version( 'shipping', true );
+
+		$first_product = WC_Helper_Product::create_simple_product();
+		$first_product->set_regular_price( '50' );
+		$first_product->save();
+		$second_product = WC_Helper_Product::create_simple_product();
+		$second_product->set_regular_price( '20' );
+		$second_product->save();
+		WC_Helper_Coupon::create_coupon(
+			'paypal-five-off',
+			array(
+				'discount_type' => 'fixed_cart',
+				'coupon_amount' => '5',
+			)
+		);
+
+		// Order subtotal after the coupon is 105, above the 100 minimum; at catalog prices it would be 65.
+		$order = wc_create_order();
+		$this->add_product_line( $order, $first_product, 1, 70.0 );
+		$this->add_product_line( $order, $second_product, 1, 40.0 );
+		$order->apply_coupon( 'paypal-five-off' );
+		$this->finalize_paypal_order( $order, $this->us_standard_rate_id, self::PAYPAL_ORDER_ID );
+		$this->order = $order;
+
+		$response = $this->dispatch_update_shipping( $this->get_us_address(), array( 'id' => $free_shipping_rate_id ) );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertSame(
+			array(
+				$this->us_standard_rate_id => false,
+				$this->us_express_rate_id  => false,
+				$free_shipping_rate_id     => true,
+			),
+			array_column( $data['purchase_units'][0]['shipping_options'], 'selected', 'id' ),
+			'Free shipping should be offered and selected'
+		);
+
+		$updated_order  = wc_get_order( $order->get_id() );
+		$shipping_items = $updated_order->get_items( 'shipping' );
+		$this->assertCount( 1, $shipping_items, 'The order should have exactly one shipping line' );
+		$this->assertSame( $free_shipping_rate_id, $this->get_rate_id( current( $shipping_items ) ) );
+		$this->assertSame( 0.0, (float) $updated_order->get_shipping_total() );
+		$this->assertSame( 105.0, (float) $updated_order->get_total() );
+		$this->assertSame( '105.00', $data['purchase_units'][0]['amount']['value'] );
+	}
+
+	/**
+	 * Add a flat rate shipping method with the given title and cost to a zone.
+	 *
+	 * @param WC_Shipping_Zone $zone  The zone.
+	 * @param string           $title The method title.
+	 * @param string           $cost  The method cost.
+	 * @return string The rate id.
+	 */
+	private function add_flat_rate( WC_Shipping_Zone $zone, string $title, string $cost ): string {
+		$instance_id = $zone->add_shipping_method( 'flat_rate' );
+		update_option(
+			"woocommerce_flat_rate_{$instance_id}_settings",
+			array(
+				'title'      => $title,
+				'tax_status' => 'taxable',
+				'cost'       => $cost,
+			)
+		);
+
+		return "flat_rate:{$instance_id}";
+	}
+
+	/**
+	 * Create a pending order for a 50.00 product shipped with the US standard rate.
+	 *
+	 * @param string   $shipping_rate_id       The shipping rate ID to use for the order.
+	 * @param string   $paypal_order_id        The PayPal order ID to set for the order.
+	 * @param int|null &$product_order_item_id The ID of the product order item that gets created.
+	 * @param string   $regular_price          The regular price of the product.
+	 * @param string   $shipping_cost          The shipping cost of the order.
+	 * @return WC_Order
+	 */
+	private function create_paypal_order( string $shipping_rate_id, string $paypal_order_id, ?int &$product_order_item_id, string $regular_price = '50', string $shipping_cost = '10' ): WC_Order {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( $regular_price );
+		$product->save();
+
+		$order                 = wc_create_order();
+		$product_order_item_id = $order->add_product( $product, 1 );
+
+		$this->finalize_paypal_order( $order, $shipping_rate_id, $paypal_order_id, $shipping_cost );
+
+		return $order;
+	}
+
+	/**
+	 * Add a US shipping address and a shipping line to an order, mark it pending and link it to a PayPal order.
+	 *
+	 * @param WC_Order $order            The order, with its line items already added.
+	 * @param string   $shipping_rate_id The shipping rate ID to use for the order.
+	 * @param string   $paypal_order_id  The PayPal order ID to set for the order.
+	 * @param string   $shipping_cost    The shipping cost of the order.
+	 */
+	private function finalize_paypal_order( WC_Order $order, string $shipping_rate_id, string $paypal_order_id, string $shipping_cost = '10' ): void {
+		$order->set_shipping_country( 'US' );
+		$order->set_shipping_state( 'CA' );
+		$order->set_shipping_postcode( '90210' );
+		$order->set_shipping_city( 'Beverly Hills' );
+
+		list( $method_id, $instance_id ) = explode( ':', $shipping_rate_id );
+		$shipping                        = new WC_Order_Item_Shipping();
+		$shipping->set_method_title( 'Standard' );
+		$shipping->set_method_id( $method_id );
+		$shipping->set_instance_id( $instance_id );
+		$shipping->set_total( $shipping_cost );
+		$order->add_item( $shipping );
+
+		$order->set_status( OrderStatus::PENDING );
+		$order->update_meta_data( '_paypal_order_id', $paypal_order_id );
+		$order->calculate_totals( true );
+		$order->save();
+	}
+
+	/**
+	 * Add a product line to an order at a unit price that may differ from the catalog price.
+	 *
+	 * @param WC_Order   $order      The order.
+	 * @param WC_Product $product    The product or variation.
+	 * @param int        $quantity   The quantity.
+	 * @param float      $unit_price The unit price charged on the order.
+	 * @param array      $variation  Variation attributes to store on the line, keyed by attribute name.
+	 * @return int The order item ID.
+	 */
+	private function add_product_line( WC_Order $order, WC_Product $product, int $quantity, float $unit_price, array $variation = array() ): int {
+		$line_total = (string) ( $unit_price * $quantity );
+		$args       = array(
+			'subtotal' => $line_total,
+			'total'    => $line_total,
+		);
+		if ( ! empty( $variation ) ) {
+			$args['variation'] = $variation;
+		}
+
+		return $order->add_product( $product, $quantity, $args );
+	}
+
+	/**
+	 * Get the variation of the helper variable product that has an "Any" number attribute.
+	 *
+	 * @return WC_Product_Variation
+	 * @throws Exception If the variation is not found.
+	 */
+	private function get_any_number_variation(): WC_Product_Variation {
+		$variable_product = WC_Helper_Product::create_variation_product();
+		foreach ( $variable_product->get_children() as $variation_id ) {
+			$variation = wc_get_product( $variation_id );
+			if ( $variation instanceof WC_Product_Variation && 'DUMMY SKU VARIABLE HUGE BLUE ANY NUMBER' === $variation->get_sku() ) {
+				return $variation;
+			}
+		}
+		throw new Exception( 'Variation with an "Any" attribute not found' );
+	}
+
+	/**
+	 * Dispatch an update-shipping request for the test order.
+	 *
+	 * @param array $shipping_address The PayPal shipping address.
+	 * @param mixed $shipping_option  The PayPal shipping option, omitted when null.
+	 * @return WP_REST_Response
+	 */
+	private function dispatch_update_shipping( array $shipping_address, $shipping_option ): WP_REST_Response {
+		$body = array(
+			'id'               => self::PAYPAL_ORDER_ID,
+			'shipping_address' => $shipping_address,
+			'purchase_units'   => array(
+				array(
+					'reference_id' => 'default',
+					'custom_id'    => wp_json_encode(
+						array(
+							'order_id'  => $this->order->get_id(),
+							'order_key' => $this->order->get_order_key(),
+						)
+					),
+				),
+			),
+		);
+		if ( null !== $shipping_option ) {
+			$body['shipping_option'] = $shipping_option;
+		}
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/paypal-standard/update-shipping' );
+		$request->set_header( 'Content-Type', 'application/json' );
+		$request->set_body( wp_json_encode( $body ) );
+
+		return $this->server->dispatch( $request );
+	}
+
+	/**
+	 * Add a metadata value to order item meta to the order.
+	 *
+	 * @param WC_Order $order                 The order to add the meta to.
+	 * @param int|null $product_order_item_id The ID of the product order item to add the meta to.
+	 * @param string   $meta_key              The meta key to store the value under.
+	 * @return string The random meta value.
+	 * @throws Exception If the product order item is not found.
+	 */
+	private function add_test_order_item_meta( WC_Order $order, ?int $product_order_item_id, string $meta_key ): string {
+		$random_meta        = wp_generate_uuid4();
+		$product_order_item = $this->get_product_order_item( $order, $product_order_item_id );
+
+		$product_order_item->update_meta_data( $meta_key, $random_meta );
+		$product_order_item->save();
+
+		return $random_meta;
+	}
+
+	/**
+	 * Get the product order item for the test order.
+	 *
+	 * @param WC_Order $order                 The order to get the product order item from.
+	 * @param int|null $product_order_item_id The ID of the product order item to get.
+	 * @return WC_Order_Item_Product
+	 * @throws Exception If the product order item is not found.
+	 */
+	private function get_product_order_item( WC_Order $order, ?int $product_order_item_id ): WC_Order_Item_Product {
+		if ( null !== $product_order_item_id ) {
+			return $order->get_item( $product_order_item_id );
+		}
+		$line_items          = $order->get_items( 'line_item' );
+		$product_order_items = array_filter( $line_items, static fn( $item ) => $item instanceof WC_Order_Item_Product );
+		if ( empty( $product_order_items ) ) {
+			throw new Exception( 'No product order items found' );
+		}
+		$product_order_item = reset( $product_order_items );
+		if ( ! ( $product_order_item instanceof WC_Order_Item_Product ) ) {
+			throw new Exception( 'Product order item not found' );
+		}
+		return $product_order_item;
+	}
+
+	/**
+	 * Get the rate id for an order shipping line.
+	 *
+	 * @param WC_Order_Item_Shipping $item The shipping line.
+	 * @return string
+	 */
+	private function get_rate_id( WC_Order_Item_Shipping $item ): string {
+		return $item->get_method_id() . ':' . $item->get_instance_id();
+	}
+
+	/**
+	 * A PayPal shipping address in the US.
+	 *
+	 * @return array
+	 */
+	private function get_us_address(): array {
+		return array(
+			'country_code' => 'US',
+			'admin_area_1' => 'NY',
+			'admin_area_2' => 'New York',
+			'postal_code'  => '10001',
+		);
+	}
+
+	/**
+	 * A PayPal shipping address in Canada.
+	 *
+	 * @return array
+	 */
+	private function get_canada_address(): array {
+		return array(
+			'country_code' => 'CA',
+			'admin_area_1' => 'ON',
+			'admin_area_2' => 'Toronto',
+			'postal_code'  => 'M5V 3L9',
+		);
+	}
+}