From a7f29ce98e6114902ca38a3770e9accc86b595e2 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 3 Sep 2024 13:42:51 +0100 Subject: [PATCH] Consolidate Store API and Core account creation handling (#50913) * Consolidate Store API and Core account creation handling * Changelog * Add hook doc * Update tests to not use global settings * Ignore lints for other hooks in file --- .../update-account-handling-consolodation | 4 + .../includes/class-wc-checkout.php | 12 +- .../includes/wc-user-functions.php | 147 ++++++++--- .../src/StoreApi/Routes/V1/Checkout.php | 228 ++---------------- .../legacy/unit-tests/customer/functions.php | 33 +-- 5 files changed, 167 insertions(+), 257 deletions(-) create mode 100644 plugins/woocommerce/changelog/update-account-handling-consolodation diff --git a/plugins/woocommerce/changelog/update-account-handling-consolodation b/plugins/woocommerce/changelog/update-account-handling-consolodation new file mode 100644 index 00000000000..6ab464440e9 --- /dev/null +++ b/plugins/woocommerce/changelog/update-account-handling-consolodation @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Consolidate Store API and Core account creation functions. diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php index 3c920e598a4..cfc36f69e7e 100644 --- a/plugins/woocommerce/includes/class-wc-checkout.php +++ b/plugins/woocommerce/includes/class-wc-checkout.php @@ -1132,7 +1132,17 @@ class WC_Checkout { ); if ( is_wp_error( $customer_id ) ) { - throw new Exception( $customer_id->get_error_message() ); + if ( 'registration-error-email-exists' === $customer_id->get_error_code() ) { + /** + * Filter the notice shown when a customer tries to register with an existing email address. + * + * @since 3.3.0 + * @param string $message The notice. + * @param string $email The email address. + */ + throw new Exception( apply_filters( 'woocommerce_registration_error_email_exists', __( 'An account is already registered with your email address. Please log in.', 'woocommerce' ), $data['billing_email'] ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + throw new Exception( $customer_id->get_error_message() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } wc_set_customer_auth_cookie( $customer_id ); diff --git a/plugins/woocommerce/includes/wc-user-functions.php b/plugins/woocommerce/includes/wc-user-functions.php index aa6e60f318a..6b653d07e0b 100644 --- a/plugins/woocommerce/includes/wc-user-functions.php +++ b/plugins/woocommerce/includes/wc-user-functions.php @@ -43,6 +43,9 @@ if ( ! function_exists( 'wc_create_new_customer' ) ) { /** * Create a new customer. * + * @since 9.4.0 Moved woocommerce_registration_error_email_exists filter to the shortcode checkout class. + * @since 9.4.0 Removed handling for generating username/password based on settings--this is consumed at form level. Here, if data is missing it will be generated. + * * @param string $email Customer email. * @param string $username Customer username. * @param string $password Customer password. @@ -55,17 +58,24 @@ if ( ! function_exists( 'wc_create_new_customer' ) ) { } if ( email_exists( $email ) ) { - return new WP_Error( 'registration-error-email-exists', apply_filters( 'woocommerce_registration_error_email_exists', __( 'An account is already registered with your email address. Please log in.', 'woocommerce' ), $email ) ); + return new WP_Error( + 'registration-error-email-exists', + sprintf( + // Translators: %s Email address. + esc_html__( 'An account is already registered with %s. Please log in or use a different email address.', 'woocommerce' ), + esc_html( $email ) + ) + ); } - if ( 'yes' === get_option( 'woocommerce_registration_generate_username', 'yes' ) && empty( $username ) ) { + if ( empty( $username ) ) { $username = wc_create_new_customer_username( $email, $args ); } $username = sanitize_user( $username ); if ( empty( $username ) || ! validate_username( $username ) ) { - return new WP_Error( 'registration-error-invalid-username', __( 'Please enter a valid account username.', 'woocommerce' ) ); + return new WP_Error( 'registration-error-invalid-username', __( 'Please provide a valid account username.', 'woocommerce' ) ); } if ( username_exists( $username ) ) { @@ -74,35 +84,88 @@ if ( ! function_exists( 'wc_create_new_customer' ) ) { // Handle password creation. $password_generated = false; - if ( 'yes' === get_option( 'woocommerce_registration_generate_password' ) && empty( $password ) ) { + + if ( empty( $password ) ) { $password = wp_generate_password(); $password_generated = true; } if ( empty( $password ) ) { - return new WP_Error( 'registration-error-missing-password', __( 'Please enter an account password.', 'woocommerce' ) ); + return new WP_Error( 'registration-error-missing-password', __( 'Please create a password for your account.', 'woocommerce' ) ); } // Use WP_Error to handle registration errors. $errors = new WP_Error(); + /** + * Fires before a customer account is registered. + * + * This hook fires before customer accounts are created and passes the form data (username, email) and an array + * of errors. + * + * This could be used to add extra validation logic and append errors to the array. + * + * @since 7.2.0 + * + * @internal Matches filter name in WooCommerce core. + * + * @param string $username Customer username. + * @param string $user_email Customer email address. + * @param \WP_Error $errors Error object. + */ do_action( 'woocommerce_register_post', $username, $email, $errors ); + /** + * Filters registration errors before a customer account is registered. + * + * This hook filters registration errors. This can be used to manipulate the array of errors before + * they are displayed. + * + * @since 7.2.0 + * + * @internal Matches filter name in WooCommerce core. + * + * @param \WP_Error $errors Error object. + * @param string $username Customer username. + * @param string $user_email Customer email address. + * @return \WP_Error + */ $errors = apply_filters( 'woocommerce_registration_errors', $errors, $username, $email ); - if ( $errors->get_error_code() ) { + if ( is_wp_error( $errors ) && $errors->get_error_code() ) { return $errors; } + // Merged passed args with sanitized username, email, and password. + $customer_data = array_merge( + $args, + array( + 'user_login' => $username, + 'user_pass' => $password, + 'user_email' => $email, + 'role' => 'customer', + ) + ); + + /** + * Filters customer data before a customer account is registered. + * + * This hook filters customer data. It allows user data to be changed, for example, username, password, email, + * first name, last name, and role. + * + * @since 7.2.0 + * + * @param array $customer_data An array of customer (user) data. + * @return array + */ $new_customer_data = apply_filters( 'woocommerce_new_customer_data', - array_merge( - $args, + wp_parse_args( + $customer_data, array( - 'user_login' => $username, - 'user_pass' => $password, - 'user_email' => $email, - 'role' => 'customer', + 'first_name' => '', + 'last_name' => '', + 'source' => 'unknown', ) ) ); @@ -113,6 +176,24 @@ if ( ! function_exists( 'wc_create_new_customer' ) ) { return $customer_id; } + // Set account flag to remind customer to update generated password. + if ( $password_generated ) { + update_user_option( $customer_id, 'default_password_nag', true, true ); + } + + /** + * Fires after a customer account has been registered. + * + * This hook fires after customer accounts are created and passes the customer data. + * + * @since 7.2.0 + * + * @internal Matches filter name in WooCommerce core. + * + * @param integer $customer_id New customer (user) ID. + * @param array $new_customer_data Array of customer (user) data. + * @param string $password_generated The generated password for the account. + */ do_action( 'woocommerce_created_customer', $customer_id, $new_customer_data, $password_generated ); return $customer_id; @@ -232,7 +313,9 @@ function wc_set_customer_auth_cookie( $customer_id ) { wp_set_auth_cookie( $customer_id, true ); // Update session. - WC()->session->init_session_cookie(); + if ( is_callable( array( WC()->session, 'init_session_cookie' ) ) ) { + WC()->session->init_session_cookie(); + } } /** @@ -272,10 +355,10 @@ function wc_update_new_customer_past_orders( $customer_id ) { do_action( 'woocommerce_update_new_customer_past_order', $order_id, $customer ); if ( $order->get_status() === 'wc-completed' ) { - $complete++; + ++$complete; } - $linked++; + ++$linked; } } @@ -356,18 +439,18 @@ function wc_customer_bought_product( $customer_email, $user_id, $product_id ) { } if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { - $statuses = array_map( + $statuses = array_map( function ( $status ) { return "wc-$status"; }, $statuses ); - $order_table = OrdersTableDataStore::get_orders_table_name(); + $order_table = OrdersTableDataStore::get_orders_table_name(); $user_id_clause = ''; if ( $user_id ) { $user_id_clause = 'OR o.customer_id = ' . absint( $user_id ); } - $sql = " + $sql = " SELECT DISTINCT im.meta_value FROM $order_table AS o INNER JOIN {$wpdb->prefix}woocommerce_order_items AS i ON o.id = i.order_id INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta AS im ON i.order_item_id = im.order_item_id @@ -568,17 +651,15 @@ function wc_modify_map_meta_cap( $caps, $cap, $user_id, $args ) { case 'delete_user': if ( ! isset( $args[0] ) || $args[0] === $user_id ) { break; - } else { - if ( ! wc_current_user_has_role( 'administrator' ) ) { - if ( wc_user_has_role( $args[0], 'administrator' ) ) { + } elseif ( ! wc_current_user_has_role( 'administrator' ) ) { + if ( wc_user_has_role( $args[0], 'administrator' ) ) { + $caps[] = 'do_not_allow'; + } elseif ( wc_current_user_has_role( 'shop_manager' ) ) { + // Shop managers can only edit customer info. + $userdata = get_userdata( $args[0] ); + $shop_manager_editable_roles = apply_filters( 'woocommerce_shop_manager_editable_roles', array( 'customer' ) ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + if ( property_exists( $userdata, 'roles' ) && ! empty( $userdata->roles ) && ! array_intersect( $userdata->roles, $shop_manager_editable_roles ) ) { $caps[] = 'do_not_allow'; - } elseif ( wc_current_user_has_role( 'shop_manager' ) ) { - // Shop managers can only edit customer info. - $userdata = get_userdata( $args[0] ); - $shop_manager_editable_roles = apply_filters( 'woocommerce_shop_manager_editable_roles', array( 'customer' ) ); - if ( property_exists( $userdata, 'roles' ) && ! empty( $userdata->roles ) && ! array_intersect( $userdata->roles, $shop_manager_editable_roles ) ) { - $caps[] = 'do_not_allow'; - } } } } @@ -596,7 +677,7 @@ add_filter( 'map_meta_cap', 'wc_modify_map_meta_cap', 10, 4 ); */ function wc_get_customer_download_permissions( $customer_id ) { $data_store = WC_Data_Store::load( 'customer-download' ); - return apply_filters( 'woocommerce_permission_list', $data_store->get_downloads_for_customer( $customer_id ), $customer_id ); + return apply_filters( 'woocommerce_permission_list', $data_store->get_downloads_for_customer( $customer_id ), $customer_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment } /** @@ -655,6 +736,7 @@ function wc_get_customer_available_downloads( $customer_id ) { } // Download name will be 'Product Name' for products with a single downloadable file, and 'Product Name - File X' for products with multiple files. + // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment $download_name = apply_filters( 'woocommerce_downloadable_product_name', $download_file['name'], @@ -688,10 +770,11 @@ function wc_get_customer_available_downloads( $customer_id ) { ), ); - $file_number++; + ++$file_number; } } + // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment return apply_filters( 'woocommerce_customer_available_downloads', $downloads, $customer_id ); } @@ -817,7 +900,7 @@ add_action( 'profile_update', 'wc_update_profile_last_update_time', 10, 2 ); * @param mixed $_meta_value Value of the meta that was changed. */ function wc_meta_update_last_update_time( $meta_id, $user_id, $meta_key, $_meta_value ) { - $keys_to_track = apply_filters( 'woocommerce_user_last_update_fields', array( 'first_name', 'last_name' ) ); + $keys_to_track = apply_filters( 'woocommerce_user_last_update_fields', array( 'first_name', 'last_name' ) ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment $update_time = in_array( $meta_key, $keys_to_track, true ) ? true : false; $update_time = 'billing_' === substr( $meta_key, 0, 8 ) ? true : $update_time; @@ -848,7 +931,7 @@ function wc_set_user_last_update_time( $user_id ) { * @return array */ function wc_get_customer_saved_methods_list( $customer_id ) { - return apply_filters( 'woocommerce_saved_payment_methods_list', array(), $customer_id ); + return apply_filters( 'woocommerce_saved_payment_methods_list', array(), $customer_id ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment } /** diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php index 243403488f5..69e72e54806 100644 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php +++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php @@ -1,14 +1,12 @@ should_create_customer_account( $request ) ) { - $customer_id = $this->create_customer_account( - $request['billing_address']['email'], - $request['billing_address']['first_name'], - $request['billing_address']['last_name'], - $request['customer_password'] + if ( $this->should_create_customer_account( $request ) ) { + $customer_id = wc_create_new_customer( + $request['billing_address']['email'], + '', + $request['customer_password'], + [ + 'first_name' => $request['billing_address']['first_name'], + 'last_name' => $request['billing_address']['last_name'], + 'source' => 'store-api', + ] + ); + + if ( is_wp_error( $customer_id ) ) { + throw new RouteException( + esc_html( $customer_id->get_error_code() ), + esc_html( $customer_id->get_error_message() ), + 400 ); - - // Associate customer with the order. This is done before login to ensure the order is associated with - // the correct customer if login fails. - $this->order->set_customer_id( $customer_id ); - $this->order->save(); - - // Log the customer in to WordPress. Doing this inline instead of using `wc_set_customer_auth_cookie` - // because wc_set_customer_auth_cookie forces the use of session cookie. - wp_set_current_user( $customer_id ); - wp_set_auth_cookie( $customer_id, true ); - - // Init session cookie if the session cookie handler exists. - if ( is_callable( [ WC()->session, 'init_session_cookie' ] ) ) { - WC()->session->init_session_cookie(); - } - } - } catch ( \Exception $error ) { - switch ( $error->getMessage() ) { - case 'registration-error-invalid-email': - throw new RouteException( - 'registration-error-invalid-email', - esc_html__( 'Please provide a valid email address.', 'woocommerce' ), - 400 - ); - case 'registration-error-email-exists': - throw new RouteException( - 'registration-error-email-exists', - sprintf( - // Translators: %s Email address. - esc_html__( 'An account is already registered with %s. Please log in or use a different email address.', 'woocommerce' ), - esc_html( $request['billing_address']['email'] ) - ), - 400 - ); - case 'registration-error-empty-password': - throw new RouteException( - 'registration-error-empty-password', - esc_html__( 'Please create a password for your account.', 'woocommerce' ), - 400 - ); } + + // Associate customer with the order. + $this->order->set_customer_id( $customer_id ); + $this->order->save(); + + // Set the customer auth cookie. + wc_set_customer_auth_cookie( $customer_id ); } // Persist customer address data to account. @@ -673,159 +648,4 @@ class Checkout extends AbstractCartRoute { return false; } - - /** - * Create a new account for a customer. - * - * The account is created with a generated username. The customer is sent - * an email notifying them about the account and containing a link to set - * their (initial) password. - * - * Intended as a replacement for wc_create_new_customer in WC core. - * - * @throws \Exception If an error is encountered when creating the user account. - * - * @param string $user_email The email address to use for the new account. - * @param string $first_name The first name to use for the new account. - * @param string $last_name The last name to use for the new account. - * @param string $password The password to use for the new account. If empty, a password will be generated. - * - * @return int User id if successful - */ - private function create_customer_account( $user_email, $first_name, $last_name, $password = '' ) { - if ( empty( $user_email ) || ! is_email( $user_email ) ) { - throw new \Exception( 'registration-error-invalid-email' ); - } - - if ( email_exists( $user_email ) ) { - throw new \Exception( 'registration-error-email-exists' ); - } - - // Handle password creation if not provided. - if ( empty( $password ) ) { - $password = wp_generate_password(); - $password_generated = true; - } else { - $password_generated = false; - } - - // This ensures `wp_generate_password` returned something (it is filterable and could be empty string). - if ( empty( $password ) ) { - throw new \Exception( 'registration-error-empty-password' ); - } - - $username = wc_create_new_customer_username( $user_email ); - - // Use WP_Error to handle registration errors. - $errors = new \WP_Error(); - - /** - * Fires before a customer account is registered. - * - * This hook fires before customer accounts are created and passes the form data (username, email) and an array - * of errors. - * - * This could be used to add extra validation logic and append errors to the array. - * - * @since 7.2.0 - * - * @internal Matches filter name in WooCommerce core. - * - * @param string $username Customer username. - * @param string $user_email Customer email address. - * @param \WP_Error $errors Error object. - */ - do_action( 'woocommerce_register_post', $username, $user_email, $errors ); - - /** - * Filters registration errors before a customer account is registered. - * - * This hook filters registration errors. This can be used to manipulate the array of errors before - * they are displayed. - * - * @since 7.2.0 - * - * @internal Matches filter name in WooCommerce core. - * - * @param \WP_Error $errors Error object. - * @param string $username Customer username. - * @param string $user_email Customer email address. - * @return \WP_Error - */ - $errors = apply_filters( 'woocommerce_registration_errors', $errors, $username, $user_email ); - - if ( is_wp_error( $errors ) && $errors->get_error_code() ) { - throw new \Exception( $errors->get_error_code() ); - } - - /** - * Filters customer data before a customer account is registered. - * - * This hook filters customer data. It allows user data to be changed, for example, username, password, email, - * first name, last name, and role. - * - * @since 7.2.0 - * - * @param array $customer_data An array of customer (user) data. - * @return array - */ - $new_customer_data = apply_filters( - 'woocommerce_new_customer_data', - array( - 'user_login' => $username, - 'user_pass' => $password, - 'user_email' => $user_email, - 'first_name' => $first_name, - 'last_name' => $last_name, - 'role' => 'customer', - 'source' => 'store-api', - ) - ); - - $customer_id = wp_insert_user( $new_customer_data ); - - if ( is_wp_error( $customer_id ) ) { - throw $this->map_create_account_error( $customer_id ); - } - - // Set account flag to remind customer to update generated password. - update_user_option( $customer_id, 'default_password_nag', true, true ); - - /** - * Fires after a customer account has been registered. - * - * This hook fires after customer accounts are created and passes the customer data. - * - * @since 7.2.0 - * - * @internal Matches filter name in WooCommerce core. - * - * @param integer $customer_id New customer (user) ID. - * @param array $new_customer_data Array of customer (user) data. - * @param string $password_generated The generated password for the account. - */ - do_action( 'woocommerce_created_customer', $customer_id, $new_customer_data, $password_generated ); - - return $customer_id; - } - - /** - * Convert an account creation error to an exception. - * - * @param \WP_Error $error An error object. - * @return \Exception. - */ - private function map_create_account_error( \WP_Error $error ) { - switch ( $error->get_error_code() ) { - // WordPress core error codes. - case 'empty_username': - case 'invalid_username': - case 'empty_email': - case 'invalid_email': - case 'email_exists': - case 'registerfail': - return new \Exception( 'woocommerce_rest_checkout_create_account_failure' ); - } - return new \Exception( 'woocommerce_rest_checkout_create_account_failure' ); - } } diff --git a/plugins/woocommerce/tests/legacy/unit-tests/customer/functions.php b/plugins/woocommerce/tests/legacy/unit-tests/customer/functions.php index c3c33acd987..0ebfa691475 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/customer/functions.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/customer/functions.php @@ -46,7 +46,7 @@ class WC_Tests_Customer_Functions extends WC_Unit_Test_Case { $id = wc_create_new_customer( 'test@example.com', 'testuser', 'testpassword' ); $this->assertInstanceOf( 'WP_Error', $id ); - // Empty username. + // Empty email. $id = wc_create_new_customer( '', 'testuser', 'testpassword' ); $this->assertInstanceOf( 'WP_Error', $id ); @@ -58,20 +58,6 @@ class WC_Tests_Customer_Functions extends WC_Unit_Test_Case { $id = wc_create_new_customer( 'test2@example.com', 'testuser', 'testpassword' ); $this->assertInstanceOf( 'WP_Error', $id ); - // Username with auto-generation. - update_option( 'woocommerce_registration_generate_username', 'yes' ); - $id = wc_create_new_customer( 'fred@example.com', '', 'testpassword' ); - $userdata = get_userdata( $id ); - $this->assertEquals( 'fred', $userdata->user_login ); - $id = wc_create_new_customer( 'fred@mail.com', '', 'testpassword' ); - $userdata = get_userdata( $id ); - $this->assertNotEquals( 'fred', $userdata->user_login ); - $this->assertStringContainsString( 'fred', $userdata->user_login ); - $id = wc_create_new_customer( 'fred@test.com', '', 'testpassword' ); - $userdata = get_userdata( $id ); - $this->assertNotEquals( 'fred', $userdata->user_login ); - $this->assertStringContainsString( 'fred', $userdata->user_login ); - // Test extra arguments to generate display_name. $id = wc_create_new_customer( 'john.doe@example.com', @@ -85,13 +71,20 @@ class WC_Tests_Customer_Functions extends WC_Unit_Test_Case { $userdata = get_userdata( $id ); $this->assertEquals( 'John Doe', $userdata->display_name ); - // No password. - update_option( 'woocommerce_registration_generate_password', 'no' ); - $id = wc_create_new_customer( 'joe@example.com', 'joecustomer', '' ); - $this->assertInstanceOf( 'WP_Error', $id ); + // Username with auto-generation. + $id = wc_create_new_customer( 'fred@example.com', '', 'testpassword' ); + $userdata = get_userdata( $id ); + $this->assertEquals( 'fred', $userdata->user_login ); + $id = wc_create_new_customer( 'fred@mail.com', '', 'testpassword' ); + $userdata = get_userdata( $id ); + $this->assertNotEquals( 'fred', $userdata->user_login ); + $this->assertStringContainsString( 'fred', $userdata->user_login ); + $id = wc_create_new_customer( 'fred@test.com', '', 'testpassword' ); + $userdata = get_userdata( $id ); + $this->assertNotEquals( 'fred', $userdata->user_login ); + $this->assertStringContainsString( 'fred', $userdata->user_login ); // Auto-generated password. - update_option( 'woocommerce_registration_generate_password', 'yes' ); $id = wc_create_new_customer( 'joe@example.com', 'joecustomer', '' ); $this->assertTrue( is_numeric( $id ) && $id > 0 ); }