Commit ff4bde0fddf for woocommerce

commit ff4bde0fddfaf052e67c30c201a5a3715acaed10
Author: Alefe Souza <contact@alefesouza.com>
Date:   Mon Oct 19 18:00:46 2026 -0300

    Add a copy address link to My Account addresses (#69848)

diff --git a/plugins/woocommerce/changelog/51647-copy-my-account-address b/plugins/woocommerce/changelog/51647-copy-my-account-address
new file mode 100644
index 00000000000..d2827ce9a23
--- /dev/null
+++ b/plugins/woocommerce/changelog/51647-copy-my-account-address
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a "Copy Billing address" or "Copy Shipping address" link in My Account > Addresses to fill an empty address in one click.
diff --git a/plugins/woocommerce/includes/class-wc-form-handler.php b/plugins/woocommerce/includes/class-wc-form-handler.php
index e1a90b6a45d..fc6e5d64a4b 100644
--- a/plugins/woocommerce/includes/class-wc-form-handler.php
+++ b/plugins/woocommerce/includes/class-wc-form-handler.php
@@ -5,6 +5,8 @@
  * @package WooCommerce\Classes\
  */

+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
+use Automattic\WooCommerce\Blocks\Package;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Enums\PaymentGatewayFeature;
 use Automattic\WooCommerce\Enums\ProductType;
@@ -48,6 +50,7 @@ class WC_Form_Handler {
 		add_action( 'template_redirect', array( __CLASS__, 'redirect_reset_password_link' ) );
 		add_action( 'template_redirect', array( __CLASS__, 'resend_set_password' ) );
 		add_action( 'template_redirect', array( __CLASS__, 'save_address' ) );
+		add_action( 'template_redirect', array( __CLASS__, 'copy_address' ) );
 		add_action( 'template_redirect', array( __CLASS__, 'save_account_details' ) );
 		add_action( 'wp_loaded', array( __CLASS__, 'checkout_action' ), 20 );
 		add_action( 'wp_loaded', array( __CLASS__, 'process_login' ), 20 );
@@ -258,48 +261,7 @@ class WC_Form_Handler {
 			}

 			if ( ! empty( $value ) ) {
-				// Validation and formatting rules.
-				if ( ! empty( $field['validate'] ) && is_array( $field['validate'] ) ) {
-					foreach ( $field['validate'] as $rule ) {
-						switch ( $rule ) {
-							case 'postcode':
-								$country = wc_clean( wp_unslash( $_POST[ $address_type . '_country' ] ) );
-								$value   = wc_format_postcode( $value, $country );
-
-								if ( '' !== $value && ! WC_Validation::is_postcode( $value, $country ) ) {
-									switch ( $country ) {
-										case 'IE':
-											$postcode_validation_notice = __( 'Please enter a valid Eircode.', 'woocommerce' );
-											break;
-										default:
-											$postcode_validation_notice = __( 'Please enter a valid postcode / ZIP.', 'woocommerce' );
-									}
-									wc_add_notice( $postcode_validation_notice, 'error' );
-								}
-								break;
-							case 'phone':
-								$country = wc_clean( wp_unslash( $_POST[ $address_type . '_country' ] ) );
-								$country = is_string( $country ) ? $country : '';
-								// Strip before validating, so a customer is not rejected over
-								// characters they cannot see. A value that is nothing but those
-								// characters is not a phone number.
-								$value = wc_remove_non_displayable_chars( (string) $value );
-								if ( '' === $value || ! WC_Validation::is_phone( $value, $country ) ) {
-									/* translators: %s: Phone number. */
-									wc_add_notice( sprintf( __( '%s is not a valid phone number.', 'woocommerce' ), '<strong>' . $field['label'] . '</strong>' ), 'error' );
-								}
-								break;
-							case 'email':
-								$value = strtolower( $value );
-
-								if ( ! is_email( $value ) ) {
-									/* translators: %s: Email address. */
-									wc_add_notice( sprintf( __( '%s is not a valid email address.', 'woocommerce' ), '<strong>' . $field['label'] . '</strong>' ), 'error' );
-								}
-								break;
-						}
-					}
-				}
+				$value = self::validate_address_field_value( $value, $field, wc_clean( wp_unslash( $_POST[ $address_type . '_country' ] ) ) );
 			}

 			try {
@@ -358,6 +320,237 @@ class WC_Form_Handler {
 		exit;
 	}

+	/**
+	 * Apply an address field's validation rules to a value, adding an error notice for each rule it fails.
+	 *
+	 * @param mixed $value   Field value.
+	 * @param array $field   Field definition from WC_Countries::get_address_fields().
+	 * @param mixed $country Country code of the address.
+	 * @return mixed The value, formatted by the postcode, phone and email rules.
+	 */
+	private static function validate_address_field_value( $value, $field, $country ) {
+		if ( empty( $field['validate'] ) || ! is_array( $field['validate'] ) ) {
+			return $value;
+		}
+
+		foreach ( $field['validate'] as $rule ) {
+			switch ( $rule ) {
+				case 'postcode':
+					$value = wc_format_postcode( $value, $country );
+
+					if ( '' !== $value && ! WC_Validation::is_postcode( $value, $country ) ) {
+						switch ( $country ) {
+							case 'IE':
+								$postcode_validation_notice = __( 'Please enter a valid Eircode.', 'woocommerce' );
+								break;
+							default:
+								$postcode_validation_notice = __( 'Please enter a valid postcode / ZIP.', 'woocommerce' );
+						}
+						wc_add_notice( $postcode_validation_notice, 'error' );
+					}
+					break;
+				case 'phone':
+					// Strip before validating, so a customer is not rejected over
+					// characters they cannot see. A value that is nothing but those
+					// characters is not a phone number.
+					$value = wc_remove_non_displayable_chars( (string) $value );
+					if ( '' === $value || ! WC_Validation::is_phone( $value, is_string( $country ) ? $country : '' ) ) {
+						/* translators: %s: Phone number. */
+						wc_add_notice( sprintf( __( '%s is not a valid phone number.', 'woocommerce' ), '<strong>' . $field['label'] . '</strong>' ), 'error' );
+					}
+					break;
+				case 'email':
+					$value = strtolower( $value );
+
+					if ( ! is_email( $value ) ) {
+						/* translators: %s: Email address. */
+						wc_add_notice( sprintf( __( '%s is not a valid email address.', 'woocommerce' ), '<strong>' . $field['label'] . '</strong>' ), 'error' );
+					}
+					break;
+			}
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Get the URL that copies the customer's other saved address into an empty address type.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string $address_type Address type to copy into; 'billing' or 'shipping'.
+	 * @return string
+	 */
+	public static function get_copy_address_url( $address_type ) {
+		$address_type = sanitize_key( $address_type );
+
+		return wp_nonce_url(
+			add_query_arg( 'copy-address', $address_type, wc_get_endpoint_url( 'edit-address', '', wc_get_page_permalink( 'myaccount' ) ) ),
+			'woocommerce-copy-address-' . $address_type
+		);
+	}
+
+	/**
+	 * Copy the saved billing address into an empty shipping address, or vice versa, from the My Account addresses page.
+	 *
+	 * The target address is only written when it has not been set up yet, so a stale link cannot overwrite an address.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return void
+	 */
+	public static function copy_address() {
+		if ( ! isset( $_GET['copy-address'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The nonce is verified below.
+			return;
+		}
+
+		$user_id = get_current_user_id();
+
+		if ( $user_id <= 0 ) {
+			return;
+		}
+
+		wc_nocache_headers();
+
+		// Browsers and link preloaders mark prefetch requests with these headers. A non-2xx response makes them discard it.
+		$purpose = wc_clean( wp_unslash( $_SERVER['HTTP_SEC_PURPOSE'] ?? $_SERVER['HTTP_PURPOSE'] ?? $_SERVER['HTTP_X_PURPOSE'] ?? $_SERVER['HTTP_X_MOZ'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized with wc_clean().
+
+		if ( is_string( $purpose ) && false !== stripos( $purpose, 'prefetch' ) ) {
+			wp_die( esc_html__( 'The address could not be copied. Please try again.', 'woocommerce' ), '', array( 'response' => 503 ) );
+		}
+
+		$target_type  = sanitize_key( wp_unslash( $_GET['copy-address'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The nonce is verified below.
+		$source_types = array(
+			'billing'  => 'shipping',
+			'shipping' => 'billing',
+		);
+		$redirect_url = wc_get_endpoint_url( 'edit-address', '', wc_get_page_permalink( 'myaccount' ) );
+		$nonce_value  = isset( $_REQUEST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ) : '';
+
+		if ( ! isset( $source_types[ $target_type ] ) ) {
+			wc_add_notice( __( 'The address could not be copied. Please try again.', 'woocommerce' ), 'error' );
+			wp_safe_redirect( $redirect_url );
+			exit;
+		}
+
+		if ( ! wp_verify_nonce( $nonce_value, 'woocommerce-copy-address-' . $target_type ) ) {
+			wc_add_notice( __( 'The address could not be copied because the link you followed may have expired. Please try again.', 'woocommerce' ), 'error' );
+			wp_safe_redirect( $redirect_url );
+			exit;
+		}
+
+		$source_type = $source_types[ $target_type ];
+
+		if ( wc_get_account_formatted_address( $target_type, $user_id ) || ! wc_get_account_formatted_address( $source_type, $user_id ) ) {
+			wp_safe_redirect( $redirect_url );
+			exit;
+		}
+
+		$customer         = new WC_Customer( $user_id );
+		$source_country   = 'billing' === $source_type ? $customer->get_billing_country( 'edit' ) : $customer->get_shipping_country( 'edit' );
+		$target_countries = 'shipping' === $target_type ? WC()->countries->get_shipping_countries() : WC()->countries->get_allowed_countries();
+
+		if ( ! array_key_exists( $source_country, $target_countries ) ) {
+			wc_add_notice( __( 'This address uses a country that is not available for the other address type. Please add the address manually.', 'woocommerce' ), 'error' );
+			wp_safe_redirect( $redirect_url );
+			exit;
+		}
+
+		$address = WC()->countries->get_address_fields( $source_country, $target_type . '_' );
+
+		foreach ( $address as $target_key => $field ) {
+			$source_key = $source_type . substr( $target_key, strlen( $target_type ) );
+			$getter     = "get_{$source_key}";
+			$setter     = "set_{$target_key}";
+			// Read stored values, so getter filters are not saved into the other address.
+			$value = is_callable( array( $customer, $getter ) ) ? $customer->{$getter}( 'edit' ) : $customer->get_meta( $source_key, true, 'edit' );
+
+			// Shipping has no email field, so fall back to the account email like the edit address form does.
+			if ( 'billing_email' === $target_key && ! $value ) {
+				$value = $customer->get_email( 'edit' );
+			}
+
+			// The other address type can require fields the copied address doesn't have, such as a billing phone.
+			if ( ! empty( $field['required'] ) && true !== ( $field['hidden'] ?? false ) && empty( $value ) ) {
+				/* translators: %s: Field name. */
+				wc_add_notice( sprintf( __( '%s is a required field.', 'woocommerce' ), $field['label'] ), 'error', array( 'id' => $target_key ) );
+			}
+
+			if ( ! empty( $value ) ) {
+				$value = self::validate_address_field_value( $value, $field, $source_country );
+			}
+
+			try {
+				if ( is_callable( array( $customer, $setter ) ) ) {
+					$customer->{$setter}( $value );
+				} else {
+					$customer->update_meta_data( $target_key, $value );
+				}
+			} catch ( WC_Data_Exception $e ) {
+				wc_add_notice( $e->getMessage(), 'error' );
+			}
+		}
+
+		$checkout_fields = Package::container()->get( CheckoutFields::class );
+		$field_values    = array();
+
+		foreach ( array_keys( $checkout_fields->get_fields_for_location( 'address' ) ) as $field_key ) {
+			$field_values[ $field_key ] = $checkout_fields->get_field_from_object( $field_key, $customer, $source_type, false );
+		}
+
+		// Validate the additional fields against the target address, like the edit address form does.
+		$errors         = new WP_Error();
+		$persist_fields = $checkout_fields->get_valid_customer_field_values( $customer, $field_values, $field_values, 'address', $target_type, $errors );
+		$errors->merge_from( $checkout_fields->validate_fields_for_location( $field_values, 'address', $target_type ) );
+
+		foreach ( $errors->get_error_messages() as $error_message ) {
+			wc_add_notice( $error_message, 'error' );
+		}
+
+		// Set the additional fields before the action below, so it sees the whole copied address. Nothing is saved on errors.
+		foreach ( $persist_fields as $field_key => $value ) {
+			$checkout_fields->persist_field_for_customer( $field_key, $value, $customer, $target_type );
+		}
+
+		/**
+		 * Fires before a customer's copied address is saved, so extensions can reject it by adding an error notice.
+		 *
+		 * Unlike woocommerce_after_save_address_validation, there's no submitted form, so read the copied values from
+		 * the customer instead of the request.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param int         $user_id     User ID being saved.
+		 * @param string      $target_type Address type being written; 'billing' or 'shipping'.
+		 * @param string      $source_type Address type being copied; 'billing' or 'shipping'.
+		 * @param WC_Customer $customer    The customer, with the copied address set but not saved.
+		 */
+		do_action( 'woocommerce_before_copy_address_save', $user_id, $target_type, $source_type, $customer );
+
+		if ( 0 < wc_notice_count( 'error' ) ) {
+			wp_safe_redirect( $redirect_url );
+			exit;
+		}
+
+		$customer->save();
+
+		/**
+		 * Fires after a customer copies one of their saved addresses into the other address type from My Account.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param int         $user_id     User ID being saved.
+		 * @param string      $target_type Address type that was written; 'billing' or 'shipping'.
+		 * @param string      $source_type Address type that was copied from; 'billing' or 'shipping'.
+		 * @param WC_Customer $customer    The customer object that was saved.
+		 */
+		do_action( 'woocommerce_customer_copy_address', $user_id, $target_type, $source_type, $customer );
+
+		wc_add_notice( __( 'Address copied successfully.', 'woocommerce' ) );
+		wp_safe_redirect( $redirect_url );
+		exit;
+	}
+
 	/**
 	 * Save the password/account details and redirect back to the my account page.
 	 */
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 358d7c21fa2..da3620e3110 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -11724,18 +11724,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-form-handler.php

-		-
-			message: '#^Parameter \#2 \$country of function wc_format_postcode expects string, array\|string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/class-wc-form-handler.php
-
-		-
-			message: '#^Parameter \#2 \$country of static method WC_Validation\:\:is_postcode\(\) expects string, array\|string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/class-wc-form-handler.php
-
 		-
 			message: '#^Parameter \#2 \$quantity of method WC_Cart\:\:add_to_cart\(\) expects int, float\|int given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
index 6f34341204f..7bcca3f3c29 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
@@ -741,6 +741,64 @@ class CheckoutFields {
 		return $fields;
 	}

+	/**
+	 * Validates customer field values one by one and returns the ones that can be saved.
+	 *
+	 * Adds an error for each required field that is empty and each value that fails its field rules. Location-level rules are
+	 * not run here; use validate_fields_for_location() for those.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param WC_Customer $customer         Customer the values are for, used to evaluate conditional rules.
+	 * @param array       $field_values     Field values keyed by field ID, used to check required fields.
+	 * @param array       $sanitized_values Sanitized field values keyed by field ID, used for validation and the returned values.
+	 * @param string      $location         The location of the fields (address|contact).
+	 * @param string      $group            The group of the fields (billing|shipping|other).
+	 * @param WP_Error    $errors           Error object that receives an error for each invalid field.
+	 * @return array Values to save keyed by field ID. Empty optional fields are included as ''.
+	 */
+	public function get_valid_customer_field_values( WC_Customer $customer, array $field_values, array $sanitized_values, string $location, string $group, WP_Error $errors ): array {
+		$document_object = new DocumentObject(
+			[
+				'customer' => [
+					( 'address' === $location ? $group . '_address' : 'additional_fields' ) => $sanitized_values,
+				],
+			]
+		);
+		$document_object->set_customer( $customer );
+		$document_object->set_context( 'address' === $location ? $group . '_address' : $location );
+
+		$valid_values = [];
+
+		foreach ( $this->get_contextual_fields_for_location( $location, $document_object ) as $field_key => $field ) {
+			if ( empty( $field_values[ $field_key ] ) ) {
+				if ( true === $field['required'] ) {
+					$errors->add(
+						'required_field',
+						/* translators: %s: is the field label */
+						sprintf( __( '%s is required', 'woocommerce' ), '<strong>' . $field['label'] . '</strong>' )
+					);
+					continue;
+				}
+				$valid_values[ $field_key ] = '';
+				continue;
+			}
+
+			$sanitized_value = $sanitized_values[ $field_key ] ?? '';
+			$valid_check     = $this->validate_field( $field, $sanitized_value );
+
+			if ( is_wp_error( $valid_check ) && $valid_check->has_errors() ) {
+				// Get one error message from the WP_Error object per field to avoid overlapping error messages.
+				$errors->add( $valid_check->get_error_code(), $valid_check->get_error_message() );
+				continue;
+			}
+
+			$valid_values[ $field_key ] = $sanitized_value;
+		}
+
+		return $valid_values;
+	}
+
 	/**
 	 * Validates a set of fields for a given location against custom validation rules.
 	 *
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
index 5ff7f251fc5..5179bd36c51 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
@@ -308,49 +308,9 @@ class CheckoutFieldsFrontend {
 		$field_values           = $this->get_posted_additional_field_values( $location, $group, false ); // These values are used to see if required fields have values.
 		$sanitized_field_values = $this->get_posted_additional_field_values( $location, $group ); // These values are used to validate custom rules, generate the document object, and save fields to the account.

-		$document_object = new DocumentObject(
-			[
-				'customer' => [
-					( 'address' === $location ? $group . '_address' : 'additional_fields' ) => $sanitized_field_values,
-				],
-			]
-		);
-		$document_object->set_customer( $customer );
-		$document_object->set_context( 'address' === $location ? $group . '_address' : $location );
-		$fields = $this->checkout_fields_controller->get_contextual_fields_for_location( $location, $document_object );
-
-		// Holds values to be persisted to the customer object.
-		$persist_fields = [];
+		// Validate individual fields. Errors are added to the $errors object, and each field is validated regardless of other field errors.
 		$errors         = new \WP_Error();
-
-		// Validate individual fields against the document object. Errors are added to the $errors object, and each field is validated regardless of other field errors.
-		foreach ( $fields as $field_key => $field ) {
-			$field_value = $field_values[ $field_key ];
-
-			if ( empty( $field_value ) ) {
-				if ( true === $field['required'] ) {
-					$errors->add(
-						'required_field',
-						/* translators: %s: is the field label */
-						sprintf( __( '%s is required', 'woocommerce' ), '<strong>' . $field['label'] . '</strong>' )
-					);
-					continue;
-				}
-				$persist_fields[ $field_key ] = '';
-				continue;
-			}
-
-			$sanitized_field_value = $sanitized_field_values[ $field_key ];
-			$valid_check           = $this->checkout_fields_controller->validate_field( $field, $sanitized_field_value );
-
-			if ( is_wp_error( $valid_check ) && $valid_check->has_errors() ) {
-				// Get one error message from the WP_Error object per field to avoid overlapping error messages.
-				$errors->add( $valid_check->get_error_code(), $valid_check->get_error_message() );
-				continue;
-			}
-
-			$persist_fields[ $field_key ] = $sanitized_field_value;
-		}
+		$persist_fields = $this->checkout_fields_controller->get_valid_customer_field_values( $customer, $field_values, $sanitized_field_values, $location, $group, $errors );

 		// Validate all fields for this location (this runs custom validation callbacks). If an error is found, no values will be persisted to the customer object.
 		$location_validation = $this->checkout_fields_controller->validate_fields_for_location( $sanitized_field_values, $location, $group );
diff --git a/plugins/woocommerce/templates/myaccount/my-address.php b/plugins/woocommerce/templates/myaccount/my-address.php
index 5bd5a9757ec..2f64cae13c3 100644
--- a/plugins/woocommerce/templates/myaccount/my-address.php
+++ b/plugins/woocommerce/templates/myaccount/my-address.php
@@ -12,7 +12,7 @@
  *
  * @see     https://woocommerce.com/document/template-structure/
  * @package WooCommerce\Templates
- * @version 9.3.0
+ * @version 11.4.0
  */

 defined( 'ABSPATH' ) || exit;
@@ -50,11 +50,24 @@ $col    = 1;
 	<div class="u-columns woocommerce-Addresses col2-set addresses">
 <?php endif; ?>

+<?php
+$formatted_addresses = array();
+foreach ( array_keys( $get_addresses ) as $name ) {
+	$formatted_addresses[ $name ] = wc_get_account_formatted_address( $name );
+}
+$copyable_from = array(
+	'billing'  => 'shipping',
+	'shipping' => 'billing',
+);
+?>
+
 <?php foreach ( $get_addresses as $name => $address_title ) : ?>
 	<?php
-		$address = wc_get_account_formatted_address( $name );
-		$col     = $col * -1;
-		$oldcol  = $oldcol * -1;
+		$address     = $formatted_addresses[ $name ];
+		$copy_source = $copyable_from[ $name ] ?? '';
+		$can_copy    = ! $address && $copy_source && ! empty( $formatted_addresses[ $copy_source ] );
+		$col         = $col * -1;
+		$oldcol      = $oldcol * -1;
 	?>

 	<div class="u-column<?php echo $col < 0 ? 1 : 2; ?> col-<?php echo $oldcol < 0 ? 1 : 2; ?> woocommerce-Address">
@@ -70,6 +83,16 @@ $col    = 1;
 				?>
 			</a>
 		</header>
+		<?php if ( $can_copy ) : ?>
+			<p class="woocommerce-Address-copy">
+				<a href="<?php echo esc_url( WC_Form_Handler::get_copy_address_url( $name ) ); ?>" class="copy no-prefetch" rel="nofollow">
+					<?php
+						/* translators: %s: Address title of the address being copied, e.g. "Billing address" */
+						printf( esc_html__( 'Copy %s', 'woocommerce' ), esc_html( $get_addresses[ $copy_source ] ) );
+					?>
+				</a>
+			</p>
+		<?php endif; ?>
 		<address>
 			<?php
 				echo $address ? wp_kses_post( $address ) : esc_html_e( 'You have not set up this type of address yet.', 'woocommerce' );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
index b9dc6ca34da..268c25bc0ef 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
@@ -7,6 +7,8 @@

 declare( strict_types = 1 );

+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
+use Automattic\WooCommerce\Blocks\Package;
 use Automattic\WooCommerce\Enums\OrderStatus;

 /**
@@ -49,6 +51,13 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
 	 */
 	private $original_session;

+	/**
+	 * IDs of additional checkout fields registered by the current test.
+	 *
+	 * @var string[]
+	 */
+	private array $registered_checkout_fields = array();
+
 	/**
 	 * Set up test fixtures.
 	 */
@@ -90,6 +99,10 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
 		wc_clear_notices();
 		WC()->session = $this->original_session;

+		foreach ( $this->registered_checkout_fields as $field_id ) {
+			__internal_woocommerce_blocks_deregister_checkout_field( $field_id );
+		}
+
 		parent::tearDown();
 	}

@@ -494,6 +507,341 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox copy_address() copies the saved $source_type address into the empty $target_type address.
+	 *
+	 * @testWith ["billing", "shipping"]
+	 *           ["shipping", "billing"]
+	 * @covers WC_Form_Handler::copy_address()
+	 *
+	 * @param string $source_type Address type copied from.
+	 * @param string $target_type Address type copied into.
+	 */
+	public function test_copy_address_copies_saved_address_into_empty_address( string $source_type, string $target_type ): void {
+		$customer  = $this->create_customer_with_address( $source_type );
+		$hook_args = null;
+		add_action(
+			'woocommerce_customer_copy_address',
+			function ( ...$args ) use ( &$hook_args ) {
+				$hook_args = $args;
+			},
+			10,
+			4
+		);
+
+		$this->prepare_copy_address_request( $target_type );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$customer = new WC_Customer( $customer->get_id() );
+
+		$this->assertEmpty( wc_get_notices( 'error' ), 'Copying a saved address should not add error notices.' );
+		$this->assertSame( 'Address copied successfully.', wc_get_notices( 'success' )[0]['notice'] ?? null );
+		foreach ( array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'phone' ) as $field ) {
+			$this->assertSame(
+				$customer->{"get_{$source_type}_{$field}"}(),
+				$customer->{"get_{$target_type}_{$field}"}(),
+				"The {$target_type}_{$field} value should match the copied {$source_type} address."
+			);
+		}
+		$this->assertSame( array( $customer->get_id(), $target_type, $source_type ), array_slice( $hook_args ?? array(), 0, 3 ), 'The copy hook should fire with the user ID and address types.' );
+	}
+
+	/**
+	 * @testdox copy_address() fires woocommerce_before_copy_address_save with the copied address set on the customer.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_fires_before_save_action_with_copied_address(): void {
+		$customer  = $this->create_customer_with_address( 'billing' );
+		$hook_args = null;
+		add_action(
+			'woocommerce_before_copy_address_save',
+			function ( $user_id, $target_type, $source_type, $hook_customer ) use ( &$hook_args ) {
+				$hook_args = array( $user_id, $target_type, $source_type, $hook_customer->get_shipping_address_1( 'edit' ) );
+			},
+			10,
+			4
+		);
+
+		$this->prepare_copy_address_request( 'shipping' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertSame( array( $customer->get_id(), 'shipping', 'billing', $customer->get_billing_address_1() ), $hook_args );
+	}
+
+	/**
+	 * @testdox copy_address() doesn't save the copied address when woocommerce_before_copy_address_save adds an error notice.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_is_rejected_by_error_in_before_save_action(): void {
+		$customer = $this->create_customer_with_address( 'billing' );
+		add_action(
+			'woocommerce_before_copy_address_save',
+			function () {
+				wc_add_notice( 'Extension rejected the address.', 'error' );
+			}
+		);
+
+		$this->prepare_copy_address_request( 'shipping' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertSame( 'Extension rejected the address.', wc_get_notices( 'error' )[0]['notice'] ?? null );
+		$this->assertEmpty( wc_get_notices( 'success' ), 'A rejected copy should not show a success notice.' );
+		$this->assertSame( '', ( new WC_Customer( $customer->get_id() ) )->get_shipping_address_1(), 'A rejected copy should not be saved.' );
+	}
+
+	/**
+	 * @testdox copy_address() uses the account email when copying a shipping address into billing.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_uses_account_email_for_billing(): void {
+		$customer = $this->create_customer_with_address( 'shipping' );
+
+		$this->prepare_copy_address_request( 'billing' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertSame( 'copy-address-customer@example.test', ( new WC_Customer( $customer->get_id() ) )->get_billing_email() );
+	}
+
+	/**
+	 * @testdox copy_address() leaves an address that is already set up untouched.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_does_not_overwrite_existing_address(): void {
+		$customer = $this->create_customer_with_address( 'billing' );
+		$customer->set_shipping_address_1( '1 Existing Road' );
+		$customer->set_shipping_country( 'US' );
+		$customer->save();
+
+		$this->prepare_copy_address_request( 'shipping' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertSame( '1 Existing Road', ( new WC_Customer( $customer->get_id() ) )->get_shipping_address_1(), 'An existing shipping address should not be overwritten.' );
+		$this->assertEmpty( wc_get_notices( 'success' ), 'No success notice should be shown when nothing was copied.' );
+	}
+
+	/**
+	 * @testdox copy_address() rejects a request with an invalid nonce.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_rejects_invalid_nonce(): void {
+		$customer = $this->create_customer_with_address( 'billing' );
+
+		$this->prepare_copy_address_request( 'shipping' );
+		$_REQUEST['_wpnonce'] = 'invalid';
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$notices = wc_get_notices( 'error' );
+		$this->assertCount( 1, $notices, 'An invalid nonce should add an error notice.' );
+		$this->assertSame( 'The address could not be copied because the link you followed may have expired. Please try again.', $notices[0]['notice'], 'An invalid nonce should say the link may have expired.' );
+		$this->assertSame( '', ( new WC_Customer( $customer->get_id() ) )->get_shipping_address_1(), 'An invalid nonce should not copy the address.' );
+	}
+
+	/**
+	 * @testdox copy_address() refuses to copy a billing address whose country the store does not ship to.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_rejects_country_not_available_for_shipping(): void {
+		$customer = $this->create_customer_with_address( 'billing' );
+		add_filter(
+			'woocommerce_countries_shipping_countries',
+			function () {
+				return array( 'CA' => 'Canada' );
+			}
+		);
+
+		$this->prepare_copy_address_request( 'shipping' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertCount( 1, wc_get_notices( 'error' ), 'A country the store does not ship to should add an error notice.' );
+		$this->assertSame( '', ( new WC_Customer( $customer->get_id() ) )->get_shipping_address_1(), 'The address should not be copied to an unavailable country.' );
+	}
+
+	/**
+	 * @testdox copy_address() copies the stored address values, not the values returned by getter filters.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_ignores_getter_filters(): void {
+		$customer = $this->create_customer_with_address( 'billing' );
+		add_filter(
+			'woocommerce_customer_get_billing_address_1',
+			function () {
+				return 'Filtered street';
+			}
+		);
+
+		$this->prepare_copy_address_request( 'shipping' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertSame( '123 Market Street', ( new WC_Customer( $customer->get_id() ) )->get_shipping_address_1( 'edit' ), 'The stored billing street should be copied, not the filtered one.' );
+	}
+
+	/**
+	 * @testdox copy_address() refuses a $header prefetch request so a hovered link does not copy the address.
+	 *
+	 * @testWith ["HTTP_SEC_PURPOSE", "prefetch"]
+	 *           ["HTTP_SEC_PURPOSE", "prefetch;prerender"]
+	 *           ["HTTP_PURPOSE", "prefetch"]
+	 *           ["HTTP_X_MOZ", "prefetch"]
+	 * @covers WC_Form_Handler::copy_address()
+	 *
+	 * @param string $header Server variable of the request header.
+	 * @param string $value  Header value.
+	 */
+	public function test_copy_address_rejects_prefetch_request( string $header, string $value ): void {
+		$customer = $this->create_customer_with_address( 'billing' );
+		$this->prepare_copy_address_request( 'shipping' );
+		$_SERVER[ $header ] = $value;
+
+		try {
+			WC_Form_Handler::copy_address();
+			$this->fail( 'Expected copy_address() to stop a prefetch request.' );
+		} catch ( WPDieException $e ) {
+			$this->assertSame( '', ( new WC_Customer( $customer->get_id() ) )->get_shipping_address_1(), 'A prefetch request should not copy the address.' );
+		} finally {
+			unset( $_SERVER[ $header ] );
+		}
+	}
+
+	/**
+	 * @testdox copy_address() does nothing for a logged-out visitor.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_ignores_logged_out_request(): void {
+		$this->prepare_copy_address_request( 'shipping' );
+
+		WC_Form_Handler::copy_address();
+
+		$this->assertEmpty( wc_get_notices(), 'A logged-out request should not add notices.' );
+	}
+
+	/**
+	 * @testdox copy_address() rejects an address type other than billing or shipping.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_rejects_unknown_address_type(): void {
+		$this->create_customer_with_address( 'billing' );
+
+		$this->prepare_copy_address_request( 'other' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertSame( 'The address could not be copied. Please try again.', wc_get_notices( 'error' )[0]['notice'] ?? null );
+		$this->assertEmpty( wc_get_notices( 'success' ), 'An unknown address type should not show a success notice.' );
+	}
+
+	/**
+	 * @testdox copy_address() does nothing when there is no saved address to copy from.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_does_nothing_without_source_address(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $user_id );
+
+		$this->prepare_copy_address_request( 'shipping' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertEmpty( wc_get_notices(), 'Nothing to copy should not add notices.' );
+		$this->assertSame( '', ( new WC_Customer( $user_id ) )->get_shipping_country(), 'No shipping address should be saved.' );
+	}
+
+	/**
+	 * @testdox copy_address() refuses to copy when the target address requires a field the source address lacks.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_rejects_missing_required_target_field(): void {
+		$customer = $this->create_customer_with_address( 'shipping' );
+		$customer->set_shipping_phone( '' );
+		$customer->save();
+		add_filter(
+			'woocommerce_billing_fields',
+			function ( $fields ) {
+				$fields['billing_phone']['required'] = true;
+				return $fields;
+			}
+		);
+
+		$this->prepare_copy_address_request( 'billing' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertSame( 'Phone is a required field.', wc_get_notices( 'error' )[0]['notice'] ?? null );
+		$this->assert_copy_address_rejected( $customer->get_id(), 'billing' );
+	}
+
+	/**
+	 * @testdox copy_address() refuses to copy a postcode that is not valid for the address country.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_rejects_invalid_postcode(): void {
+		$customer = $this->create_customer_with_address( 'billing' );
+		$customer->set_billing_postcode( 'NOT-A-ZIP' );
+		$customer->save();
+
+		$this->prepare_copy_address_request( 'shipping' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertSame( 'Please enter a valid postcode / ZIP.', wc_get_notices( 'error' )[0]['notice'] ?? null );
+		$this->assert_copy_address_rejected( $customer->get_id(), 'shipping' );
+	}
+
+	/**
+	 * @testdox copy_address() copies additional address fields registered through the Checkout block.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_copies_additional_checkout_fields(): void {
+		$this->register_checkout_field(
+			array(
+				'id'       => 'test-namespace/gate-code',
+				'label'    => 'Gate code',
+				'location' => 'address',
+			)
+		);
+		$customer        = $this->create_customer_with_address( 'billing' );
+		$checkout_fields = Package::container()->get( CheckoutFields::class );
+		$checkout_fields->persist_field_for_customer( 'test-namespace/gate-code', '1234', $customer, 'billing' );
+		$customer->save();
+
+		$this->prepare_copy_address_request( 'shipping' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertEmpty( wc_get_notices( 'error' ), 'Copying additional fields should not add error notices.' );
+		$this->assertSame( '1234', $checkout_fields->get_field_from_object( 'test-namespace/gate-code', new WC_Customer( $customer->get_id() ), 'shipping' ) );
+	}
+
+	/**
+	 * @testdox copy_address() refuses to copy when a required Checkout block address field is empty.
+	 *
+	 * @covers WC_Form_Handler::copy_address()
+	 */
+	public function test_copy_address_rejects_missing_required_additional_checkout_field(): void {
+		$this->register_checkout_field(
+			array(
+				'id'       => 'test-namespace/gate-code',
+				'label'    => 'Gate code',
+				'location' => 'address',
+				'required' => true,
+			)
+		);
+		$customer = $this->create_customer_with_address( 'billing' );
+
+		$this->prepare_copy_address_request( 'shipping' );
+		$this->dispatch_copy_address_expecting_redirect();
+
+		$this->assertSame( '<strong>Gate code</strong> is required', wc_get_notices( 'error' )[0]['notice'] ?? null );
+		$this->assert_copy_address_rejected( $customer->get_id(), 'shipping' );
+	}
+
 	/**
 	 * @testdox save_account_details() saves other account fields when an email-like display name is unchanged.
 	 *
@@ -791,4 +1139,78 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {

 		$this->fail( 'Expected save_address() to redirect after a successful save. Notices: ' . wp_json_encode( wc_get_notices() ) );
 	}
+
+	/**
+	 * Creates the current customer with only one saved address type.
+	 *
+	 * @param string $address_type Address type to save.
+	 * @return WC_Customer
+	 */
+	private function create_customer_with_address( string $address_type ): WC_Customer {
+		$user_id = self::factory()->user->create(
+			array(
+				'user_login' => 'copy-address-customer',
+				'user_email' => 'copy-address-customer@example.test',
+				'role'       => 'customer',
+			)
+		);
+		wp_set_current_user( $user_id );
+
+		$customer = new WC_Customer( $user_id );
+		foreach ( self::provide_address_types()[ $address_type ][1] as $field => $value ) {
+			$customer->{"set_{$address_type}_{$field}"}( $value );
+		}
+		$customer->save();
+
+		return $customer;
+	}
+
+	/**
+	 * Prepares a copy address request for the given target address type.
+	 *
+	 * @param string $target_type Address type to copy into.
+	 */
+	private function prepare_copy_address_request( string $target_type ): void {
+		$_GET     = array( 'copy-address' => $target_type );
+		$_REQUEST = array( '_wpnonce' => wp_create_nonce( 'woocommerce-copy-address-' . $target_type ) );
+	}
+
+	/**
+	 * Dispatches the copy address handler and expects a redirect back to Addresses.
+	 */
+	private function dispatch_copy_address_expecting_redirect(): void {
+		try {
+			WC_Form_Handler::copy_address();
+		} catch ( RuntimeException $e ) {
+			$this->assertSame(
+				wc_get_endpoint_url( 'edit-address', '', wc_get_page_permalink( 'myaccount' ) ),
+				$e->getMessage(),
+				'Copying an address should redirect to Addresses.'
+			);
+			return;
+		}
+
+		$this->fail( 'Expected copy_address() to redirect. Notices: ' . wp_json_encode( wc_get_notices() ) );
+	}
+
+	/**
+	 * Asserts that a copy was refused: no success notice and the target address is still empty.
+	 *
+	 * @param int    $customer_id Customer ID.
+	 * @param string $target_type Address type the copy targeted.
+	 */
+	private function assert_copy_address_rejected( int $customer_id, string $target_type ): void {
+		$this->assertEmpty( wc_get_notices( 'success' ), 'A refused copy should not show a success notice.' );
+		$this->assertSame( '', ( new WC_Customer( $customer_id ) )->{"get_{$target_type}_address_1"}(), "The {$target_type} address should not be saved." );
+	}
+
+	/**
+	 * Registers an additional checkout field for the current test.
+	 *
+	 * @param array $args Field arguments.
+	 */
+	private function register_checkout_field( array $args ): void {
+		woocommerce_register_additional_checkout_field( $args );
+		$this->registered_checkout_fields[] = $args['id'];
+	}
 }