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'];
+ }
}