get_signup_page('register');
if ( ! is_a($register, '\WP_Post')) {
return;
}
$register_slug = $register->post_name;
/*
* The first rewrite rule.
*
* This will match the registration URL and a plan
* slug.
*
* Example: site.com/register/premium
* Will pre-select the premium product.
*/
add_rewrite_rule(
"{$register_slug}\/([0-9a-zA-Z-_]+)[\/]?$",
'index.php?pagename=' . $register_slug . '&products[]=$matches[1]&wu_preselected=products',
'top'
);
/*
* This one is here for backwards compatibility.
* It always assign to months.
*/
add_rewrite_rule(
"{$register_slug}\/([0-9a-zA-Z-_]+)\/([0-9]+)[\/]?$",
'index.php?pagename=' . $register_slug . '&products[]=$matches[1]&duration=$matches[2]&duration_unit=month&wu_preselected=products',
'top'
);
/*
* This is the one we really want.
* It allows us to create custom registration URLs
* such as /register/premium/1/year
*/
add_rewrite_rule(
"{$register_slug}\/([0-9a-zA-Z-_]+)\/([0-9]+)[\/]?([a-z]+)[\/]?$",
'index.php?pagename=' . $register_slug . '&products[]=$matches[1]&duration=$matches[2]&duration_unit=$matches[3]&wu_preselected=products',
'top'
);
/*
* By the default, the template selection
* URL structure uses the word template.
* This can be changed using the filter below.
*/
$template_slug = apply_filters('wu_template_selection_rewrite_rule_slug', 'template', $register_slug);
/*
* Template site pre-selection.
* Allows for registration urls such as
* /register/template/starter
*/
add_rewrite_rule(
"{$register_slug}\/{$template_slug}\/([0-9a-zA-Z-_]+)[\/]?$",
'index.php?pagename=' . $register_slug . '&template_name=$matches[1]&wu_preselected=template_id',
'top'
);
}
/**
* Filters the wu_request with the query vars.
*
* @since 2.0.0
*
* @param mixed $value The value from wu_request.
* @param string $key The key value.
* @return mixed
*/
public function get_checkout_from_query_vars($value, $key) {
if ( ! did_action('wp')) {
return $value;
}
$from_query = get_query_var($key);
$cart_arguments = apply_filters(
'wu_get_checkout_from_query_vars',
[
'products',
'duration',
'duration_unit',
'template_id',
'wu_preselected',
'resume_checkout',
]
);
/**
* Deal with site templates in a specific manner.
*
* @since 2.0.8
*/
if ('template_id' === $key) {
$template_name = get_query_var('template_name', null);
if (null !== $template_name) {
$d = wu_get_site_domain_and_path($template_name);
$wp_site = get_site_by_path($d->domain, $d->path);
$site = $wp_site ? wu_get_site($wp_site->blog_id) : false;
if ($site && $site->get_type() === Site_Type::SITE_TEMPLATE) {
return $site->get_id();
}
}
}
/*
* Otherwise, simply check for its existence
* on the query object.
*/
if (in_array($key, $cart_arguments, true) && $from_query) {
return $from_query;
}
return $value;
}
/**
* Setups the necessary boilerplate code to have checkouts work.
*
* @since 2.0.0
* @param \WP_Ultimo\UI\Checkout_Element $element The checkout element.
* @return void
*/
public function setup_checkout($element = null): void {
if ($this->already_setup) {
return;
}
$checkout_form_slug = wu_request('checkout_form');
if (wu_request('pre-flight')) {
$checkout_form_slug = false;
$_REQUEST['pre_selected'] = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
}
if ( ! $checkout_form_slug && is_a($element, \WP_Ultimo\UI\Checkout_Element::class)) {
$pre_loaded_checkout_form_slug = $element->get_pre_loaded_attribute('slug', $checkout_form_slug);
$checkout_form_slug = $pre_loaded_checkout_form_slug ?: $checkout_form_slug;
}
$this->checkout_form = wu_get_checkout_form_by_slug($checkout_form_slug);
if (null === $this->session) {
$this->session = wu_get_session('signup');
}
// Handle resume checkout from URL
$resume_hash = wu_request('resume_checkout');
if ($resume_hash) {
$resume_payment = wu_get_payment_by_hash($resume_hash);
if ($resume_payment && $resume_payment->get_status() === Payment_Status::DRAFT) {
$this->session->set('draft_payment_id', $resume_payment->get_id());
$saved_session = $resume_payment->get_meta('checkout_session');
if ($saved_session) {
$this->session->set('signup', $saved_session);
}
}
}
// Handle cancel pending payment request
if (wu_request('cancel_pending_payment')) {
$payment_id = wu_request('cancel_pending_payment');
$payment = wu_get_payment($payment_id);
if ($payment && $payment->get_status() === Payment_Status::PENDING && $this->can_user_cancel_payment($payment)) {
$payment->set_status(Payment_Status::CANCELLED);
$payment->save();
// Clear session if it was this payment
if ((int) $this->session->get('payment_id') === (int) $payment_id) {
$this->session->set('payment_id', null);
}
}
}
// Load from draft payment if exists
$draft_payment_id = $this->session->get('draft_payment_id');
if ($draft_payment_id) {
$draft_payment = wu_get_payment($draft_payment_id);
if ($draft_payment && $draft_payment->get_status() === Payment_Status::DRAFT) {
$saved_session = $draft_payment->get_meta('checkout_session');
if ($saved_session) {
$this->session->set('signup', array_merge($this->session->get('signup') ?? [], $saved_session));
}
} else {
// Invalid draft, remove
$this->session->set('draft_payment_id', null);
}
}
// Detect pending payments
// $this->detect_pending_payments();
if ($this->checkout_form) {
$this->steps = $this->checkout_form->get_steps_to_show();
$first_step = current($this->steps);
$step_name = wu_request('checkout_step', wu_get_isset($first_step, 'id', 'checkout'));
$this->step_name = $step_name;
$this->step = $this->checkout_form->get_step($this->step_name, true);
if (! $this->step) {
$this->step = [];
}
$this->step['fields'] ??= [];
$this->auto_submittable_field = $this->contains_auto_submittable_field($this->step['fields']);
$this->step['fields'] = wu_create_checkout_fields($this->step['fields']);
}
if (is_user_logged_in()) {
$_REQUEST['user_id'] = get_current_user_id();
}
$this->already_setup = true;
// Create draft payment if products selected and no draft exists
$products = $this->request_or_session('products', []);
if (! empty($products) && ! $this->session->get('draft_payment_id')) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf
// TODO: Get this working, later.
// $this->create_draft_payment($products);
}
wu_no_cache(); // Prevent the registration page from being cached.
}
/**
* Checks if a list of fields has an auto-submittable field.
*
* @since 2.0.4
*
* @param array $fields The list of fields of a step we need to check.
* @return false|string False if no auto-submittable field is present, the field to watch otherwise.
*/
public function contains_auto_submittable_field($fields) {
$relevant_fields = [];
$field_types_to_ignore = [
'hidden',
'products',
'submit_button',
'period_selection',
'steps',
];
// Extra check to prevent error messages from being displayed.
if ( ! is_array($fields)) {
$fields = [];
}
foreach ($fields as $field) {
if (in_array($field['type'], $field_types_to_ignore, true)) {
continue;
}
$relevant_fields[] = $field;
if (count($relevant_fields) > 1) {
return false;
}
}
if ( ! $relevant_fields) {
return false;
}
$auto_submittable_field = $relevant_fields[0]['type'];
return wu_get_isset($this->get_auto_submittable_fields(), $auto_submittable_field, false);
}
/**
* Returns a list of auto-submittable fields.
*
* @since 2.0.4
* @return array
*/
public function get_auto_submittable_fields() {
/**
* They key should be the signup field ID to search for,
* while the value should be the parameter we should watch for changes
* so we can submit the form when we detect one.
*/
$auto_submittable_fields = [
'template_selection' => 'template_id',
'pricing_table' => 'products',
];
return apply_filters('wu_checkout_get_auto_submittable_fields', $auto_submittable_fields, $this);
}
/**
* Decides if we want to handle a step submission or a full checkout submission.
*
* @since 2.0.0
* @return void
*/
public function maybe_handle_order_submission(): void {
if (apply_filters('wu_checkout_skip_order_submission', false, $this)) {
return;
}
$this->setup_checkout();
check_ajax_referer('wu_checkout');
if ($this->is_last_step()) {
$this->handle_order_submission();
} else {
$validation = $this->validate();
if (is_wp_error($validation)) {
wp_send_json_error($validation);
}
/*
* Persist the validated step immediately. The front-end still performs a
* native form submit to advance the browser to the next step, but relying on
* that second request can lose first-step account fields before final AJAX
* validation in some multi-step flows.
*/
$this->persist_current_step_to_session();
// Auto-save progress to draft payment
$this->save_draft_progress();
wp_send_json_success([]);
}
}
/**
* Validates the order submission, and then delegates the processing to the gateway.
*
* We use database transactions in here to prevent failed sign-ups from being
* committed to the database. This means that if a \Throwable or a WP_Error
* happens anywhere in the process, we halt it and rollback on writes up to that point.
*
* @since 2.0.0
* @return void
*/
public function handle_order_submission(): void {
global $wpdb;
$wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
try {
/**
* Fires after the checkout database transaction starts.
*
* Site provisioning listeners can use this to defer work that must read
* newly-created checkout records from another database connection.
*
* @since 2.15.2
* @param Checkout $checkout The checkout instance.
*/
do_action('wu_checkout_transaction_started', $this);
/*
* Allow developers to intercept an order submission.
*/
do_action('wu_before_handle_order_submission', $this);
/*
* Here's where we actually process the order.
*
* Throwables are caught and they rollback
* any database writes sent up until this point.
*
* @see process_order below.
* @since 2.0.0
*/
$results = $this->process_order();
/*
* Allow developers to change the results an order submission.
*/
do_action('wu_after_handle_order_submission', $results, $this);
if (is_wp_error($results)) {
$this->errors = $results;
}
} catch (\Throwable $e) {
wu_maybe_log_error($e);
$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$this->errors = new \WP_Error('exception-order-submission', $e->getMessage(), $e->getTrace());
}
if (is_wp_error($this->errors)) {
$wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
/**
* Fires after the checkout database transaction rolls back.
*
* @since 2.15.2
* @param \WP_Error $errors The checkout errors that caused the rollback.
* @param Checkout $checkout The checkout instance.
*/
try {
do_action('wu_checkout_transaction_rolled_back', $this->errors, $this);
} catch (\Throwable $e) {
/*
* The transaction is already rolled back. A lifecycle listener must
* not prevent checkout from returning the original error response.
*/
wu_maybe_log_error($e);
}
wp_send_json_error($this->errors);
}
$wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
/**
* Fires after the checkout database transaction commits.
*
* @since 2.15.2
* @param array $results Checkout result data.
* @param Checkout $checkout The checkout instance.
*/
try {
do_action('wu_checkout_transaction_committed', $results, $this);
} catch (\Throwable $e) {
/*
* The order is durable at this point. Log extension failures instead
* of returning an error that could prompt a duplicate payment attempt.
*/
wu_maybe_log_error($e);
}
// Clean up draft payment if it exists
$draft_payment_id = $this->session->get('draft_payment_id');
if ($draft_payment_id) {
$draft_payment = wu_get_payment($draft_payment_id);
if ($draft_payment && $draft_payment->get_status() === Payment_Status::DRAFT) {
$draft_payment->delete();
}
}
$this->session->set('signup', []);
$this->session->set('draft_payment_id', null);
$this->session->commit();
wp_send_json_success($results);
}
/**
* Process an order.
*
* This method is responsible for
* creating all the data elements we
* need in order to actually process a
* checkout.
*
* Those include:
* - A customer;
* - A pending payment;
* - A membership.
*
* With those elements, we can then
* delegate to the gateway to run their
* own preparations (@see run_preflight).
*
* We then return everything to be added
* to the front-end form. That data then
* gets submitted with the rest of the form,
* and eventually handled by process_checkout.
*
* @see process_checkout
*
* @since 2.0.0
* @return mixed[]|\WP_Error
*/
public function process_order() {
global $current_site, $wpdb;
/*
* First, we start to work on the cart object.
* We need to take into consideration the date we receive from
* the form submission.
*/
$cart = new Cart(
apply_filters(
'wu_cart_parameters',
[
'products' => $this->request_or_session('products', []),
'discount_code' => $this->request_or_session('discount_code'),
'country' => $this->request_or_session('billing_country'),
'state' => $this->request_or_session('billing_state'),
'city' => $this->request_or_session('billing_city'),
'membership_id' => $this->request_or_session('membership_id'),
'payment_id' => $this->request_or_session('payment_id'),
'auto_renew' => $this->request_or_session('auto_renew', false),
'duration' => $this->request_or_session('duration'),
'duration_unit' => $this->request_or_session('duration_unit'),
'cart_type' => $this->request_or_session('cart_type', 'new'),
'custom_amounts' => $this->request_or_session('custom_amounts', []),
'pwyw_recurring' => $this->request_or_session('pwyw_recurring', []),
],
$this
)
);
/*
* Check if our order is valid.
*
* The is valid method checks for
* cart setup issues, as well as
*/
if ($cart->is_valid() === false) {
return $cart->get_errors();
}
/*
* Update the checkout type
* based on the cart type we have on hand.
*/
$this->type = $cart->get_cart_type();
/*
* Block duplicate signup for logged-in users with active memberships.
*
* When a customer with an active subscription goes through the
* registration form again as a "new" checkout (rather than
* upgrade/downgrade), the new subscription replaces the old one
* and any applied coupons or custom pricing are lost.
*
* This guard fires early — before any customer, membership, or
* payment records are created — so there are no orphaned records
* to clean up when the checkout is blocked.
*
* The multiple-memberships setting supplies the default policy, and
* `wu_allow_duplicate_signup` allows extensions to refine it per checkout.
*
* @since 2.5.1
*/
if ('new' === $this->type && is_user_logged_in()) {
$existing_customer = wu_get_current_customer();
if ($existing_customer) {
$active_memberships = wu_get_memberships(
[
'customer_id' => $existing_customer->get_id(),
'status__in' => [
Membership_Status::ACTIVE,
Membership_Status::TRIALING,
Membership_Status::ON_HOLD,
],
'number' => 1,
]
);
if ( ! empty($active_memberships)) {
$existing_membership = reset($active_memberships);
/**
* Filters whether to allow a duplicate signup when the
* customer already has an active membership.
*
* Return `true` to allow the checkout to proceed. By default,
* this follows the global multiple-memberships setting.
*
* @since 2.5.1
*
* @param bool $allow Whether to allow the duplicate signup.
* @param \WP_Ultimo\Models\Membership $membership The existing active membership.
* @param \WP_Ultimo\Models\Customer $customer The current customer.
* @param \WP_Ultimo\Checkout\Cart $cart The cart being processed.
*/
$allow = apply_filters(
'wu_allow_duplicate_signup',
wu_multiple_memberships_enabled(),
$existing_membership,
$existing_customer,
$cart
);
if ( ! $allow) {
/*
* Build the account URL on the customer's own subsite
* rather than the main network site. The "account" admin
* page lives in each subsite's wp-admin. Using
* wu_get_main_site_id() would force the main-site domain
* which breaks when domain mapping is active.
*
* Falls back to the main site only if the membership has
* no published sites yet (pending site scenario).
*/
$membership_sites = $existing_membership->get_sites();
$account_blog_id = ! empty($membership_sites) ? $membership_sites[0]->get_id() : wu_get_main_site_id();
$account_url = get_admin_url($account_blog_id, 'admin.php?page=account');
return new \WP_Error(
'duplicate_signup',
sprintf(
/* translators: %s is a link to the account page. */
__('You already have an active subscription. To manage your existing subscription, visit your account page. If you need help, please contact support.', 'ultimate-multisite'),
esc_url($account_url)
)
);
}
}
}
}
/*
* Gets the gateway object we want to use.
*
* This will have been set on a previous step (session)
* or is going to be passed via the form (request)
*/
$gateway_id = $this->request_or_session('gateway');
$gateway = wu_get_gateway($gateway_id);
/*
* We need to handle free payments separately.
*
* In the same manner, if the order
* IS NOT free, we need to make sure
* the customer is not trying to game the system
* passing the free gateway to get an free account.
*
* That's what's we checking on the else case.
*/
if ($cart->should_collect_payment() === false) {
$gateway = wu_get_gateway('free');
} elseif ( ! $gateway || $gateway->get_id() === 'free' || ! array_key_exists($gateway_id, wu_get_active_gateway_as_options())) {
return new \WP_Error('no-gateway', __('Payment gateway not registered.', 'ultimate-multisite'));
}
/*
* If we do not have a gateway object,
* we need to bail.
*/
if ( ! $gateway) {
return new \WP_Error('no-gateway', __('Payment gateway not registered.', 'ultimate-multisite'));
}
$this->gateway_id = $gateway->get_id();
/*
* Set the order early so that validation_rules()
* can check should_collect_payment() to skip
* billing field requirements for free trials.
*/
$this->order = $cart;
/*
* Now we need to validate the form.
*
* Here we use the validation rules set.
* @see validation_rules
*/
$validation = $this->validate();
/*
* Bail on error.
*/
if (is_wp_error($validation)) {
return $validation;
}
/*
* Handles display names, if needed.
*/
add_filter('pre_user_display_name', [$this, 'handle_display_name']);
/*
* If we get to this point, most of the validations are done.
* Now, we will actually begin to create new data elements
* if necessary.
*
* First, we need to check for a customer.
*/
$this->customer = $this->maybe_create_customer();
/*
* We encountered errors while trying to create
* a new customer or retrieve an existing one.
*/
if (is_wp_error($this->customer)) {
return $this->customer;
}
/*
* Next, we need to create a membership.
*
* The cart object has a couple of handy methods
* that allow us to easily convert from it
* to an array of data that we can use
* to create a membership.
*/
$this->membership = $this->maybe_create_membership();
/*
* We encountered errors while trying to create
* a new membership or retrieve an existing one.
*/
if (is_wp_error($this->membership)) {
return $this->membership;
}
/*
* Next step: maybe create a site.
*
* Depending on the status of the cart,
* we might need to create a pending site to
* attach to the membership.
*/
$this->pending_site = $this->maybe_create_site();
/*
* It's not really possible to get a wp error
* in here for now but completeness dictates I add this.
*/
if (is_wp_error($this->pending_site)) {
return $this->pending_site;
}
/*
* Next, we need to create a payment.
*
* The cart object has a couple of handy methods
* that allow us to easily convert from it
* to an array of data that we can use
* to create a payment.
*/
$this->payment = $this->maybe_create_payment();
/*
* We encountered errors while trying to create
* a new payment or retrieve an existing one.
*/
if (is_wp_error($this->payment)) {
return $this->payment;
}
/**
* Keep the cart used in checkout for later reference.
*/
$this->payment->update_meta('wu_original_cart', $this->order);
/*
* Hey champs!
*
* If we are here, we have almost everything we
* need. Now is time to prepare things to hand
* over to the gateway.
*/
$this->order->set_customer($this->customer);
$this->order->set_membership($this->membership);
$this->order->set_payment($this->payment);
/**
* Fires after the checkout order is fully assembled.
*
* Addons can use this to create secondary memberships
* for products with independent billing cycles.
*
* @since 2.5.0
*
* @param \WP_Ultimo\Checkout\Cart $order The cart/order object.
* @param \WP_Ultimo\Models\Customer $customer The customer.
* @param \WP_Ultimo\Models\Membership $membership The primary membership.
* @param \WP_Ultimo\Models\Payment $payment The payment.
*/
do_action('wu_checkout_order_created', $this->order, $this->customer, $this->membership, $this->payment);
$gateway->set_order($this->order);
/*
* Before we move on,
* let's check if the user is logged in,
* and if not, let's do that.
*/
$login_result = $this->login_customer_after_checkout();
if (is_wp_error($login_result)) {
return $login_result;
}
/*
* Action time.
*
* Here's where we actually call the gateway
* and build the success data we want to return to the
* front-end form.
*/
try {
/*
* Checks for free memberships.
*/
if ($this->order->is_free() && $this->order->get_recurring_total() === 0.0 && $this->customer->get_email_verification() !== 'pending') {
if ($this->order->get_plan_id() === $this->membership->get_plan_id()) {
$this->membership->set_status(Membership_Status::ACTIVE);
$this->membership->save();
}
/**
* Trigger payment received manually.
*
* @since 2.0.10
*/
$gateway->trigger_payment_processed($this->payment, $this->membership);
} elseif ($this->order->has_trial()) {
$this->membership->set_date_trial_end(gmdate('Y-m-d 23:59:59', $this->order->get_billing_start_date()));
$this->membership->set_date_expiration(gmdate('Y-m-d 23:59:59', $this->order->get_billing_start_date()));
if (wu_get_setting('allow_trial_without_payment_method', false) && $this->customer->get_email_verification() !== 'pending') {
/*
* In this particular case, we need to set the status to trialing
* here as we will not update the membership afterward. Saving the
* transition lets Membership_Manager defer site publication until
* the surrounding checkout transaction commits.
*/
$this->membership->set_status(Membership_Status::TRIALING);
}
$this->membership->save();
/**
* Trigger payment received manually.
*
* @since 2.0.10
*/
$gateway->trigger_payment_processed($this->payment, $this->membership);
}
$success_data = [
'nonce' => wp_create_nonce('wp-ultimo-register-nonce'),
'customer' => $this->customer->to_search_results(),
'total' => $this->order->get_total(),
'recurring_total' => $this->order->get_recurring_total(),
'membership_id' => $this->membership->get_id(),
'payment_id' => $this->payment->get_id(),
'cart_type' => $this->order->get_cart_type(),
'auto_renew' => $this->order->should_auto_renew(),
'gateway' => [
'slug' => $gateway->get_id(),
'data' => [],
],
];
/*
* Let's the gateway do its thing.
*
* Here gateways will run pre-flight code
* such as setting up payment intents and other
* important things that we need to be able to finish
* the process.
*/
$result = $gateway->run_preflight();
/*
* Attach the gateway results to the return array.
*/
$success_data['gateway']['data'] = $result && is_array($result) ? $result : [];
/*
* On error, bail.
*/
if (is_wp_error($result)) {
return $result;
}
} catch (\Throwable $e) {
wu_maybe_log_error($e);
return new \WP_Error('exception', $e->getMessage(), $e->getTrace());
}
/**
* Allow developers to triggers additional hooks.
*
* @since 2.0.9
*
* @param Checkout $checkout The checkout object instance.
* @param Cart $cart The checkout cart instance.
* @return void
*/
do_action('wu_checkout_after_process_order', $this, $this->order);
/*
* All set!
*/
return $success_data;
}
/**
* Checks if a customer exists, otherwise, creates a new one.
*
* @since 2.0.0
* @return \WP_Ultimo\Models\Customer|\WP_Error
*/
protected function maybe_create_customer() {
/*
* Check if we have
* a customer for the current user.
*/
$this->created_user_id = 0;
$customer = wu_get_current_customer();
/*
* Get the form slug to save with the customer.
*/
$form_slug = $this->checkout_form ? $this->checkout_form->get_slug() : 'none';
/*
* We don't have one,
* so we'll need to create it.
*
* We can't return early because we need
* to set and validate the billing address,
* and that happens at the end of this method.
*/
if (empty($customer)) {
$this->created_user_id = 0;
$username = $this->request_or_session('username');
/*
* Handles auto-generation based on the email address.
*/
if ($this->request_or_session('auto_generate_username') === 'email') {
$username = wu_username_from_email(
$this->request_or_session('email_address'),
[
'first_name' => $this->request_or_session('first_name', ''),
'last_name' => $this->request_or_session('last_name', ''),
]
);
}
/*
* Resolve the password: use the submitted value, or generate one
* when the auto_generate_password flag is present in the session.
*
* When the password is auto-generated (e.g. the simple checkout
* preset), we deliberately leave $password empty in the data
* passed to wu_create_customer() so the user-creation path falls
* through to register_new_user(). That mirrors what WordPress
* does when an admin adds a user without a password from
* wp-admin/user-new.php — the user receives the standard
* "set your password" notification email. Once the user exists
* we apply the auto-generated password via wp_set_password()
* below so the immediate auto-login path still works for the
* rest of this request.
*/
$submitted_password = $this->request_or_session('password');
$auto_generate_password = (bool) $this->request_or_session('auto_generate_password');
$generated_password = '';
if ($auto_generate_password) {
$generated_password = wp_generate_password(16, true, false);
$password_for_user = ''; // Triggers register_new_user() path with notification email.
} else {
$password_for_user = $submitted_password;
}
/*
* If we get to this point,
* we don't have an existing customer.
*
* Next step then would be to create one.
*/
$customer_data = [
'username' => $username,
'email' => sanitize_email((string) $this->request_or_session('email_address')),
'require_new_user' => true,
'password' => $password_for_user,
'email_verification' => $this->get_customer_email_verification_status(),
'signup_form' => $form_slug,
'meta' => [],
];
/*
* If the user is logged in,
* link only to the authenticated user ID, never a submitted email.
*/
if ($this->is_existing_user()) {
$customer_data = [
'user_id' => get_current_user_id(),
'email_verification' => 'verified',
];
} else {
$existing_wp_user = get_user_by('email', $customer_data['email']);
if ($existing_wp_user) {
/*
* A logged-out checkout cannot prove ownership of an existing
* WordPress account by submitting its email address. Always
* require authentication first, even when the user has no
* Ultimate Multisite customer record yet.
*/
return new \WP_Error('email_exists', __('The email address you entered is already in use. Please log in to continue checkout with this account.', 'ultimate-multisite'));
}
}
/*
* Tries to create it.
*/
$created_user_id = 0;
$customer = wu_create_customer($customer_data, $created_user_id);
/*
* Something failed, bail.
*/
if (is_wp_error($customer)) {
return $customer;
}
if ($created_user_id && ! $this->is_existing_user() && (int) $customer->get_user_id() === $created_user_id) {
$this->created_user_id = $created_user_id;
}
/*
* If the password was auto-generated, apply it to the user we
* just created so the same-request auto-login (see
* login_customer_after_checkout()) and any code that needs
* a known credential within this request keeps working.
*
* The "set your password" notification email has already been
* dispatched by register_new_user() inside wu_create_customer().
* The reset-password link in that email remains valid because
* it is keyed on the user_login + a fresh activation key, not
* on the user's stored password hash.
*
* @since 2.6.0
*/
if ($auto_generate_password && $generated_password && ! is_wp_error($customer)) {
$new_user_id = $customer->get_user_id();
if ($this->created_user_id && (int) $new_user_id === $this->created_user_id) {
wp_set_password($generated_password, $new_user_id);
}
}
}
/*
* Store the current blog ID so the verification email
* links back to the same domain the customer used to
* check out. Without this, the verification URL would
* always point to the main site, where the customer's
* auth cookie may not be valid.
*
* @since 2.5.0
*/
$customer->set_checkout_blog_id(get_current_blog_id());
/*
* Updates IP, and country
*/
$customer->update_last_login(true, true);
/*
* Next, we need to validate the billing address,
* and save it.
*/
$billing_address = $customer->get_billing_address();
$session = $this->session->get('signup') ?? [];
$billing_address->load_attributes_from_post($session);
/*
* Validates the address when payment is being collected.
*/
if ($this->should_collect_payment()) {
$valid_address = $billing_address->validate();
if (is_wp_error($valid_address)) {
return $valid_address;
}
}
$customer->set_billing_address($billing_address);
$address_saved = $customer->save();
/*
* This should rarely happen, but if something goes
* wrong with the customer update, we return a general error.
*/
if ( ! $address_saved) {
return new \WP_Error('address_failure', __('Something wrong happened while attempting to save the customer billing address', 'ultimate-multisite'));
}
/*
* Handle meta fields.
*
* Gets all the meta fields for customers and
* save them to the customer as meta.
*/
$this->handle_customer_meta_fields($customer, $form_slug);
/**
* Allow plugin developers to do additional stuff when the customer
* is added.
*
* Here's where we add the hooks for adding the customer->user to
* the main site as well, for example.
*
* @since 2.0.0
* @param Customer $customer The customer that was maybe created.
* @param Checkout $checkout The current checkout class.
*/
do_action('wu_maybe_create_customer', $customer, $this);
/*
* Otherwise, get the customer back.
*/
return $customer;
}
/**
* Save meta data related to customers.
*
* @since 2.0.0
*
* @param Customer $customer The created customer.
* @param string $form_slug The form slug.
* @return void
*/
protected function handle_customer_meta_fields($customer, $form_slug) {
if (empty($form_slug) || 'none' === $form_slug) {
return;
}
$checkout_form = wu_get_checkout_form_by_slug($form_slug);
if ($checkout_form) {
$customer_meta_fields = $checkout_form->get_all_meta_fields('customer_meta');
$meta_repository = [];
foreach ($customer_meta_fields as $customer_meta_field) {
/*
* Adds to the repository so we can save it again.
* in filters, if we need be.
*/
$meta_repository[ $customer_meta_field['id'] ] = $this->request_or_session($customer_meta_field['id']);
wu_update_customer_meta(
$customer->get_id(),
$customer_meta_field['id'],
$this->request_or_session($customer_meta_field['id']),
$customer_meta_field['type'],
$customer_meta_field['name']
);
}
/**
* Allow plugin developers to save meta
* data in different ways if they need to.
*
* @since 2.0.0
* @param array $meta_repository The list of meta fields, key => value structured.
* @param Customer $customer The Ultimate Multisite customer object.
* @param Checkout $checkout The checkout class.
*/
do_action('wu_handle_customer_meta_fields', $meta_repository, $customer, $this);
/**
* Do basically the same thing, now for user meta.
*
* @since 2.0.4
*/
$user_meta_fields = $checkout_form->get_all_meta_fields('user_meta');
$user = $customer->get_user();
$user_meta_repository = [];
foreach ($user_meta_fields as $user_meta_field) {
/*
* Adds to the repository so we can save it again.
* in filters, if we need be.
*/
$user_meta_repository[ $user_meta_field['id'] ] = $this->request_or_session($user_meta_field['id']);
update_user_meta($customer->get_user_id(), $user_meta_field['id'], $this->request_or_session($user_meta_field['id']));
}
/**
* Allow plugin developers to save user meta
* data in different ways if they need to.
*
* @since 2.0.4
* @param array $meta_repository The list of meta fields, key => value structured.
* @param WP_User $user The WordPress user object.
* @param Customer $customer The Ultimate Multisite customer object.
* @param Checkout $checkout The checkout class.
*/
do_action('wu_handle_user_meta_fields', $user_meta_repository, $user, $customer, $this);
}
}
/**
* Checks if a membership exists, otherwise, creates a new one.
*
* @since 2.0.0
* @return \WP_Ultimo\Models\Membership|\WP_Error
*/
protected function maybe_create_membership() {
/*
* The first thing we'll do is check the cart
* to see if a membership was passed.
*/
if ($this->order->get_membership()) {
return $this->order->get_membership();
}
/*
* If that's not the case,
* we'll need to create a new one.
*
* The cart object has a couple of handy methods
* that allow us to easily convert from it
* to an array of data that we can use
* to create a membership.
*/
$membership_data = $this->order->to_membership_data();
/*
* Append additional data to the membership.
*/
$membership_data['customer_id'] = $this->customer->get_id();
$membership_data['user_id'] = $this->customer->get_user_id();
$membership_data['gateway'] = $this->gateway_id;
$membership_data['signup_method'] = wu_request('signup_method');
/*
* Important dates.
*
* A null billing start date represents a lifetime membership. A zero
* billing start date means a recurring product has no trial and billing
* starts immediately; zero must not be formatted as a Unix timestamp or
* the persisted membership expiration becomes January 1970. Use the next
* charge date as the first cycle expiration in that case.
*/
$billing_start_date = $this->order->get_billing_start_date();
$expiration_timestamp = $billing_start_date;
if (0 === $expiration_timestamp) {
$expiration_timestamp = $this->order->has_recurring()
? $this->order->get_billing_next_charge_date()
: null;
}
$membership_data['date_expiration'] = null !== $expiration_timestamp
? gmdate('Y-m-d 23:59:59', (int) $expiration_timestamp)
: null;
$membership = wu_create_membership($membership_data);
$discount_code = $this->order->get_discount_code();
if ($discount_code) {
$membership->set_discount_code($discount_code);
$membership->save();
}
return $membership;
}
/**
* Checks if a pending site exists, otherwise, creates a new one.
*
* @since 2.0.0
* @return bool|\WP_Ultimo\Models\Site|\WP_Error
*/
protected function maybe_create_site() {
/*
* Let's get a list of membership sites.
* This list includes pending sites as well.
*/
$sites = $this->membership->get_sites();
/*
* Decide if we should create a new site or not.
*
* When should we create a new pending site?
* There are a couple of rules:
* - The membership must not have a pending site;
* - The membership must not have an existing site;
*
* The get_sites method already includes pending sites,
* so we can safely rely on it.
*/
if ( ! empty($sites)) {
/*
* Return the first site that has a valid blog ID.
*
* We explicitly check for a site with a positive ID rather
* than blindly using current($sites), which could return a
* pending or stale entry if the array pointer was moved.
*
* @since 2.5.0
*/
foreach ($sites as $site) {
if ($site && method_exists($site, 'get_id') && 0 < $site->get_id()) {
return $site;
}
}
// Fallback to actual first entry if no valid site found
return reset($sites);
}
$site_url = $this->request_or_session('site_url');
$site_title = $this->request_or_session('site_title');
// Handle special auto-generation values passed from form fields
if ('autogenerate' === $site_title) {
if ($this->customer) {
$site_title = $this->customer->get_username();
} else {
$email = $this->request_or_session('email_address');
$site_title = $email ? wu_generate_site_title_from_email($email) : '';
}
}
if ('autogenerate' === $site_url && $site_title) {
/*
* Check if the base URL from user-provided title is already taken
* BEFORE auto-appending a number. Users should be told "that name
* is taken" instead of silently getting e.g. "honeys1".
*
* @since 2.4.13
*/
$base_url = wu_generate_site_url_from_title($site_title);
$d_check = wu_get_site_domain_and_path($base_url, $this->request_or_session('site_domain'));
if (domain_exists($d_check->domain, $d_check->path)) {
return new \WP_Error(
'site_url_taken',
__('That site name is already taken. Please choose a different name.', 'ultimate-multisite')
);
}
$site_url = wu_generate_unique_site_url($site_title, $this->request_or_session('site_domain'));
} elseif ('autogenerate' === $site_url && $this->customer) {
$site_url = wu_generate_unique_site_url($this->customer->get_username(), $this->request_or_session('site_domain'));
}
if ( ! $site_url && ! $site_title) {
return false;
}
$auto_generate_url = $this->request_or_session('auto_generate_site_url');
$site_title = ! $site_title && ! $auto_generate_url ? $site_url : $site_title;
/*
* Let's handle auto-generation of site URLs.
*
* To decide if we need to auto-generate the site URL,
* we'll check the request for the auto_generate_site_url value.
*
* If that's present and no site_url is present, then we need to auto-generate this.
* We support generating from either username or site_title.
*/
if (empty($site_url) || in_array($auto_generate_url, ['username', 'site_title'], true)) {
if ('username' === $auto_generate_url) {
$site_url = wu_generate_unique_site_url($this->customer->get_username(), $this->request_or_session('site_domain'));
$site_title = $site_title ?: $site_url;
} elseif ('site_title' === $auto_generate_url && $site_title) {
/*
* When generating URL from user-provided title, check if the
* base URL is already taken BEFORE auto-appending a number.
*
* @since 2.4.13
*/
$base_url = wu_generate_site_url_from_title($site_title);
$d_check = wu_get_site_domain_and_path($base_url, $this->request_or_session('site_domain'));
if (domain_exists($d_check->domain, $d_check->path)) {
return new \WP_Error(
'site_url_taken',
__('That site name is already taken. Please choose a different name.', 'ultimate-multisite')
);
}
$site_url = wu_generate_unique_site_url($site_title, $this->request_or_session('site_domain'));
} else {
// Fallback to legacy behavior - generate from site title if available
$site_url = wu_generate_site_url_from_title($site_title);
if (empty($site_url)) {
$site_url = $this->customer->get_username();
}
// Ensure uniqueness
$site_url = wu_generate_unique_site_url($site_url, $this->request_or_session('site_domain'));
}
}
$d = wu_get_site_domain_and_path($site_url, $this->request_or_session('site_domain'));
/*
* Validates the site url.
*/
$results = wpmu_validate_blog_signup($site_url, $site_title, $this->customer->get_user());
if ($results['errors']->has_errors()) {
return $results['errors'];
}
/*
* Get the form slug to save with the customer.
*/
$form_slug = $this->checkout_form ? $this->checkout_form->get_slug() : 'none';
/*
* Get the transient data to save with the site
* that way we can use it when actually registering
* the site on WordPress.
*/
$transient = [];
if ($this->checkout_form) {
$site_meta_fields = $this->checkout_form->get_all_fields();
foreach ($site_meta_fields as $site_meta_field) {
/*
* Removes password fields from transient data,
* to make sure plain passwords do not get stored
* on the database.
*/
if (str_contains((string) $site_meta_field['id'], 'password') ) {
continue;
}
$transient[ $site_meta_field['id'] ] = $this->request_or_session($site_meta_field['id']);
}
}
/*
* Gets the template id from the request.
* Here, there's some logic we need to do to
* try to get the template id if we get a
* template name instead of a number.
*
* This logic is handled inside the
* get_checkout_from_query_vars() method.
*
* @see get_checkout_from_query_vars()
*/
$template_id = apply_filters('wu_checkout_template_id', (int) $this->request_or_session('template_id'), $this->membership, $this);
/*
* Determine the site type based on the product type.
* Demo products create demo sites that auto-expire.
*/
$site_type = Site_Type::CUSTOMER_OWNED;
$plan = $this->order->get_plan();
if ($plan && $plan->get_type() === \WP_Ultimo\Database\Products\Product_Type::DEMO) {
$site_type = Site_Type::DEMO;
}
$site_data = [
'domain' => $d->domain,
'path' => $d->path,
'title' => $site_title,
'template_id' => $template_id,
'customer_id' => $this->customer->get_id(),
'membership_id' => $this->membership->get_id(),
'transient' => $transient,
'signup_options' => $this->get_site_meta_fields($form_slug, 'site_option'),
'signup_meta' => $this->get_site_meta_fields($form_slug, 'site_meta'),
'type' => $site_type,
];
return $this->membership->create_pending_site($site_data);
}
/**
* Gets list of site meta data.
*
* @since 2.0.0
*
* @param string $form_slug The form slug.
* @param string $meta_type The meta type. Can be site_meta or site_option.
* @return array
*/
protected function get_site_meta_fields($form_slug, $meta_type = 'site_meta') {
if (empty($form_slug) || 'none' === $form_slug) {
return [];
}
$checkout_form = wu_get_checkout_form_by_slug($form_slug);
$list = [];
if ($checkout_form) {
$site_meta_fields = $checkout_form->get_all_meta_fields($meta_type);
foreach ($site_meta_fields as $site_meta_field) {
$list[ $site_meta_field['id'] ] = $this->request_or_session($site_meta_field['id']);
}
}
return $list;
}
/**
* Checks if a pending payment exists, otherwise, creates a new one.
*
* @since 2.0.0
* @return \WP_Ultimo\Models\Payment|\WP_Error
*/
protected function maybe_create_payment() {
/*
* The first thing we'll do is check the cart
* to see if a payment was passed.
*/
$payment = $this->order->get_payment();
if ($payment) {
/**
* Set the gateway in existing payment
*/
if ($payment->get_gateway() !== $this->gateway_id) {
$payment->set_gateway($this->gateway_id);
$payment->save();
}
return $this->order->get_payment();
}
/*
* The membership might have a previous payment.
* We'll go ahead and cancel that one out in cases
* of a upgrade/downgrade or add-on.
*/
$previous_payment = $this->membership->get_last_pending_payment();
$cancel_types = [
'upgrade',
'downgrade',
'addon',
];
if ($previous_payment && in_array($this->type, $cancel_types, true)) {
$previous_payment->set_status(Payment_Status::CANCELLED);
/*
* This can actually return a wp_error,
* but to be honest, we don't really care if we
* were able to cancel the previous payment or not.
*/
$previous_payment->save();
}
/*
* If that's not the case,
* we'll need to create a new one.
*
* The cart object has a couple of handy methods
* that allow us to easily convert from it
* to an array of data that we can use
* to create a payment.
*/
$payment_data = $this->order->to_payment_data();
/*
* Append additional data to the payment.
*/
$payment_data['customer_id'] = $this->customer->get_id();
$payment_data['membership_id'] = $this->membership->get_id();
$payment_data['gateway'] = $this->gateway_id;
/*
* A trial payment must represent only the amount due today. The cart
* includes recurring line items so it can retain the future renewal quote,
* but persisting those values on the initial payment records revenue that
* has not been collected yet.
*/
if ($this->order->has_trial()) {
$payment_data['tax_total'] = 0;
$payment_data['subtotal'] = 0;
$payment_data['refund_total'] = 0;
$payment_data['total'] = 0;
foreach ($this->order->get_line_items() as $line_item) {
if ($line_item->is_recurring()) {
continue;
}
$payment_data['tax_total'] += $line_item->get_tax_total();
if ( ! in_array($line_item->get_type(), ['discount', 'credit'], true)) {
$payment_data['subtotal'] += $line_item->get_subtotal();
}
$payment_data['total'] += $line_item->get_total();
}
$payment_data['tax_total'] = max(0, $payment_data['tax_total']);
$payment_data['subtotal'] = max(0, $payment_data['subtotal']);
$payment_data['total'] = max(0, $payment_data['total']);
/*
* Keep the full line-item list. A retry must retain the selected plan
* and its recurring price even though it is not payable today.
*/
}
/*
* If this is a free order and a downgrade we need
* to handle the status here as the payment is not
* passed to process_checkout method in this case.
*/
if ( ! $this->order->should_collect_payment() && 'downgrade' === $this->type) {
$payment_data['status'] = Payment_Status::COMPLETED;
}
/*
* Create new payment.
*/
$payment = wu_create_payment($payment_data);
if (is_wp_error($payment)) {
// Log the error and return it to halt order processing
wu_log_add(
'checkout',
sprintf(
'Failed to create payment during order submission: %s (Code: %s)',
$payment->get_error_message(),
$payment->get_error_code()
),
\Psr\Log\LogLevel::ERROR
);
return $payment;
}
return $payment;
}
/**
* Validates the checkout form to see if it's valid por not.
*
* @since 2.0.0
* @return void
*/
public function validate_form(): void {
$validation = $this->validate();
if (is_wp_error($validation)) {
wp_send_json_error($validation);
}
wp_send_json_success();
}
/**
* Creates an order object to display the order summary tables.
*
* @since 2.0.0
* @return void
*/
public function create_order(): void {
if (apply_filters('wu_checkout_skip_create_order', false, $this)) {
return;
}
$this->setup_checkout();
// Set billing address to be used on the order
$country = ! empty($this->request_or_session('country')) ? $this->request_or_session('country') : $this->request_or_session('billing_country', '');
$state = ! empty($this->request_or_session('state')) ? $this->request_or_session('state') : $this->request_or_session('billing_state', '');
$city = ! empty($this->request_or_session('city')) ? $this->request_or_session('city') : $this->request_or_session('billing_city', '');
$cart = new Cart(
apply_filters(
'wu_cart_parameters',
[
'products' => $this->request_or_session('products', []),
'discount_code' => $this->request_or_session('discount_code'),
'country' => $country,
'state' => $state,
'city' => $city,
'membership_id' => $this->request_or_session('membership_id'),
'payment_id' => $this->request_or_session('payment_id'),
'auto_renew' => $this->request_or_session('auto_renew', false),
'duration' => $this->request_or_session('duration'),
'duration_unit' => $this->request_or_session('duration_unit'),
'cart_type' => $this->request_or_session('cart_type', 'new'),
'custom_amounts' => $this->request_or_session('custom_amounts', []),
'pwyw_recurring' => $this->request_or_session('pwyw_recurring', []),
],
$this
)
);
/**
* Calculate state and city options, if necessary.
*
* @since 2.0.11
*/
$country_data = wu_get_country($cart->get_country());
wp_send_json_success(
[
'order' => $cart->done(),
'states' => wu_key_map_to_array($country_data->get_states_as_options(), 'code', 'name'),
'cities' => wu_key_map_to_array($country_data->get_cities_as_options($state), 'code', 'name'),
'uses_postal_code' => $country_data->get_uses_postal_code(),
'labels' => [
'state_field' => $country_data->get_administrative_division_name(null, true),
'city_field' => $country_data->get_municipality_name(null, true),
],
]
);
}
/**
* Checks if a user exists with given email or username.
*
* Used for inline login prompt on checkout form.
* Implements rate limiting to prevent user enumeration attacks.
*
* @since 2.0.20
* @return void
*/
public function check_user_exists(): void {
if (apply_filters('wu_checkout_skip_user_exists_check', false, $this)) {
return;
}
check_ajax_referer('wu_checkout');
$field_type = wu_request('field_type');
$value = sanitize_text_field(wu_request('value'));
if (empty($value) || empty($field_type)) {
wp_send_json_error(['message' => __('Invalid request', 'ultimate-multisite')]);
}
// Rate limiting: 10 checks per minute per IP
$ip = wu_get_ip();
$transient_key = 'wu_check_user_' . md5($ip);
$check_count = get_transient($transient_key);
if ($check_count) {
// Deliberate delay to prevent timing attacks
usleep(100000); // 100ms
if ($check_count > 10) {
wp_send_json_error(['message' => __('Too many requests. Please try again later.', 'ultimate-multisite')]);
}
}
set_transient($transient_key, ($check_count ? $check_count + 1 : 1), MINUTE_IN_SECONDS);
$user_exists = false;
if ('email' === $field_type) {
$user = get_user_by('email', $value);
$user_exists = false !== $user;
} elseif ('username' === $field_type) {
$user = get_user_by('login', $value);
$user_exists = false !== $user;
}
wp_send_json_success(
[
'exists' => $user_exists,
]
);
}
/**
* Handles inline login during checkout.
*
* Authenticates user and sets auth cookie on success.
* Implements rate limiting to prevent brute force attacks.
*
* @since 2.0.20
* @return void
*/
public function handle_inline_login(): void {
if (apply_filters('wu_checkout_skip_inline_login', false, $this)) {
return;
}
check_ajax_referer('wu_checkout');
$username_or_email = sanitize_text_field(wu_request('username_or_email'));
$password = wu_request('password'); // Don't sanitize passwords
if (empty($username_or_email) || empty($password)) {
wp_send_json_error(
[
'message' => __('Please provide both username/email and password.', 'ultimate-multisite'),
]
);
}
// Rate limiting: 5 failed attempts per IP with 5-minute lockout
$ip = wu_get_ip();
$transient_key = 'wu_login_attempt_' . md5($ip);
$attempt_count = get_transient($transient_key);
if ($attempt_count && $attempt_count >= 5) {
wp_send_json_error(
[
'message' => __('Too many login attempts. Please try again in a few minutes.', 'ultimate-multisite'),
]
);
}
// Determine if input is email or username
if (is_email($username_or_email)) {
$user = get_user_by('email', $username_or_email);
if ($user) {
$username = $user->user_login;
} else {
$username = $username_or_email; // Will fail authentication
}
} else {
$username = $username_or_email;
}
/**
* Filters inline login before authentication.
*
* Allows plugins (e.g. captcha) to validate additional fields
* before wp_authenticate() is called. Return a WP_Error to block login.
*
* @since 2.5.0
*
* @param null|\WP_Error $result Null to proceed, WP_Error to block.
* @param string $username The username being authenticated.
*/
$pre_auth = apply_filters('wu_before_inline_login', null, $username);
if (is_wp_error($pre_auth)) {
set_transient($transient_key, ($attempt_count ? $attempt_count + 1 : 1), 5 * MINUTE_IN_SECONDS);
wp_send_json_error(
[
'message' => $pre_auth->get_error_message(),
]
);
}
// Attempt authentication using WordPress core
$user = wp_authenticate($username, $password);
if (is_wp_error($user)) {
// Increment failed attempt counter
set_transient($transient_key, ($attempt_count ? $attempt_count + 1 : 1), 5 * MINUTE_IN_SECONDS);
$error_message = $user->get_error_message();
// Strip HTML tags but keep the text content for JSON response.
$error_message = wp_strip_all_tags($error_message);
// Fallback if the error message is empty.
if (empty($error_message)) {
$error_message = __('Invalid username or password.', 'ultimate-multisite');
}
wp_send_json_error(
[
'message' => $error_message,
]
);
}
// Clear rate limiting on successful login
delete_transient($transient_key);
// Log the user in
wp_clear_auth_cookie();
wp_set_auth_cookie($user->ID, false);
do_action('wp_login', $user->user_login, $user);
// Get customer data if exists
$customer = wu_get_customer_by_user_id($user->ID);
wp_send_json_success(
[
'message' => __('Login successful!', 'ultimate-multisite'),
'user_id' => $user->ID,
'display_name' => $user->display_name,
'customer' => $customer ? $customer->to_search_results() : null,
]
);
}
/**
* Returns the checkout variables.
*
* @since 2.0.0
* @return array
*/
public function get_checkout_variables() {
global $current_site;
/*
* Localized strings.
*/
$i18n = [
'loading' => __('Loading...', 'ultimate-multisite'),
'added_to_order' => __('The item was added!', 'ultimate-multisite'),
'weak_password' => __('The Password entered is too weak.', 'ultimate-multisite'),
'password_required' => __('Password is required', 'ultimate-multisite'),
'login_failed' => __('Login failed. Please try again.', 'ultimate-multisite'),
'logging_in' => __('Logging in...', 'ultimate-multisite'),
'already_have_account' => __('Already have an account?', 'ultimate-multisite'),
'sign_in' => __('Sign in', 'ultimate-multisite'),
'forgot_password' => __('Forgot password?', 'ultimate-multisite'),
'cancel' => __('Cancel', 'ultimate-multisite'),
'email_exists' => __('A customer with the same email address or username already exists.', 'ultimate-multisite'),
'provisioning_site' => __('Provisioning your site — this can take up to 60 seconds.', 'ultimate-multisite'),
'processing' => __('Processing — please wait', 'ultimate-multisite'),
'recording_responses' => __('Recording Your Responses...', 'ultimate-multisite'),
// Client-side validation messages (%s = field label, %d = numeric limit).
/* translators: %s: field label */
'field_required' => __('%s is required.', 'ultimate-multisite'),
/* translators: %s: field label */
'field_invalid_email' => __('%s must be a valid email address.', 'ultimate-multisite'),
/* translators: 1: field label, 2: minimum character count */
'field_min_length' => __('%1$s must be at least %2$d characters.', 'ultimate-multisite'),
/* translators: 1: field label, 2: maximum character count */
'field_max_length' => __('%1$s must not exceed %2$d characters.', 'ultimate-multisite'),
/* translators: %s: field label */
'field_alpha_dash' => __('%s may only contain letters, numbers, dashes, and underscores.', 'ultimate-multisite'),
/* translators: %s: field label */
'field_lowercase' => __('%s must be lowercase.', 'ultimate-multisite'),
/* translators: 1: field label, 2: other field label */
'field_same' => __('%1$s must match %2$s.', 'ultimate-multisite'),
/* translators: %s: field label */
'field_integer' => __('%s must be a whole number.', 'ultimate-multisite'),
/* translators: %s: field label */
'field_accepted' => __('%s must be accepted.', 'ultimate-multisite'),
];
/*
* Get the default gateway.
*
* Preserve any previously saved gateway from the signup session
* so multi-step redirects don't lose the user's earlier choice.
* When no saved gateway is present, only pre-select when there is
* exactly one active gateway AND the cart requires payment — paid
* gateways must never be seeded for free checkouts.
*/
$active_gateways = array_keys(wu_get_active_gateway_as_options());
$saved_gateway = $this->request_or_session('gateway', '');
$default_gateway = '';
if ($this->should_collect_payment()) {
if ($saved_gateway && in_array($saved_gateway, $active_gateways, true)) {
$default_gateway = $saved_gateway;
} elseif (1 === count($active_gateways)) {
$default_gateway = current($active_gateways);
}
}
$d = wu_get_site_domain_and_path('replace');
$site_domain = str_replace('replace.', '', (string) $d->domain);
$duration = $this->request_or_session('duration');
$duration_unit = $this->request_or_session('duration_unit');
// If duration is not set we check for a previous period_selection field in form to use;
if (empty($duration) && $this->steps) {
foreach ($this->steps as $step) {
foreach ($step['fields'] as $field) {
if ('period_selection' === $field['type']) {
$duration = $field['period_options'][0]['duration'];
$duration_unit = $field['period_options'][0]['duration_unit'];
break;
}
}
if ($duration) {
break;
}
}
}
$products = array_merge($this->request_or_session('products', []), wu_request('products', []));
$geolocation = \WP_Ultimo\Geolocation::geolocate_ip('', true);
$billing_country = $this->request_or_session('billing_country', $geolocation['country']);
$billing_country_data = wu_get_country($billing_country);
/*
* Set the default variables.
*/
$variables = [
'i18n' => $i18n,
'ajaxurl' => wu_ajax_url(),
'late_ajaxurl' => wu_ajax_url('init'),
'baseurl' => remove_query_arg('pre-flight', wu_get_current_url()),
'country' => $billing_country,
'state' => $this->request_or_session('billing_state', $geolocation['state']),
'city' => $this->request_or_session('billing_city'),
'uses_postal_code' => $billing_country_data ? $billing_country_data->get_uses_postal_code() : true,
'duration' => $duration,
'duration_unit' => $duration_unit,
'site_title' => $this->request_or_session('site_title'),
'site_url' => $this->request_or_session('site_url') === 'autogenerate' ? '' : $this->request_or_session('site_url'),
'site_domain' => $this->request_or_session('site_domain', preg_replace('#^https?://#', '', $site_domain)),
'is_subdomain' => is_subdomain_install(),
'gateway' => $this->request_or_session('gateway', $default_gateway),
'needs_billing_info' => true,
'auto_renew' => true,
'products' => array_unique($products),
'is_last_step' => $this->is_last_step(),
/**
* Whether the site is created after checkout returns (the thank-you page
* then shows step-by-step progress) rather than during the submit request.
*
* @since 2.18.0
*
* @param bool $async True unless sites are forced to publish synchronously.
* @param Checkout $checkout Checkout.
*/
'provisioning_async' => (bool) apply_filters('wu_checkout_async_provisioning', ! wu_get_setting('force_publish_sites_sync', false), $this),
];
/*
* There's a couple of things we need to determine.
*
* First, we need to check for a payment parameter.
*/
$payment_hash = wu_request('payment');
/*
* If a hash exists, we need to retrieve the ID.
*/
$payment = wu_get_payment_by_hash($payment_hash);
$payment_id = $payment ? $payment->get_id() : 0;
/*
* With the payment id in hand, we can
* we do not pass the products, as this is
* a retry.
*/
if ($payment_id) {
$variables['payment_id'] = $payment_id;
}
/*
* The next case we need to take care of
* are addons, upgrades and downgrades.
*
* Those occur when we have a membership hash present
* and additional products, including or not a plan.
*/
$membership_hash = wu_request('membership');
/*
* If a hash exists, we need to retrieve the ID.
*/
$membership = wu_get_membership_by_hash($membership_hash);
$membership_id = $membership ? $membership->get_id() : 0;
/*
* With the membership id in hand, we can
* we do not pass the products, as this is
* a retry.
*/
if ($membership_id) {
$variables['membership_id'] = $membership_id;
}
[$plan, $other_products] = wu_segregate_products($variables['products']);
$variables['plan'] = $plan ? $plan->get_id() : 0;
/*
* Try to fetch the template_id
*/
$variables['template_id'] = $this->request_or_session('template_id', 0);
/*
* Let's also create a cart object,
* so we can pre-configure the form on the front-end
* accordingly.
*/
$variables['order'] = (new Cart($variables))->done();
/*
* Always expose discount_code as a string so wu_checkout.discount_code
* is never undefined in JS. An undefined value causes the Vue watcher
* to fire a spurious create_order() call on page load when v-init sets
* the field to an empty string.
*/
$variables['discount_code'] = '';
if ( ! empty($variables['order']->discount_code)) {
$variables['discount_code'] = $variables['order']->discount_code->get_code();
}
/*
* Expose validation rules and field labels to JS so client-side
* validation stays in sync with the server-side rules without
* duplicating logic.
*/
$variables['validation_rules'] = $this->get_js_validation_rules();
/*
* Build a field_labels map (field_id => human-readable label) from the
* checkout form fields so the JS validator can show friendly names.
*/
$field_labels = [
'email_address' => __('Email address', 'ultimate-multisite'),
'email_address_confirmation' => __('Email address confirmation', 'ultimate-multisite'),
'username' => __('Username', 'ultimate-multisite'),
'password' => __('Password', 'ultimate-multisite'),
'password_conf' => __('Password confirmation', 'ultimate-multisite'),
'site_title' => __('Site title', 'ultimate-multisite'),
'site_url' => __('Site URL', 'ultimate-multisite'),
'billing_country' => __('Country', 'ultimate-multisite'),
'billing_zip_code' => __('ZIP / Postal code', 'ultimate-multisite'),
'billing_state' => __('State / Province', 'ultimate-multisite'),
'billing_city' => __('City', 'ultimate-multisite'),
];
if ($this->checkout_form) {
foreach ($this->checkout_form->get_all_fields() as $field) {
if ( ! empty($field['id']) && ! empty($field['name'])) {
$field_labels[ $field['id'] ] = $field['name'];
}
}
}
$variables['field_labels'] = $field_labels;
/*
* Build a step_fields map (step_id => [field_ids]) so the JS validator
* can restrict client-side validation to only the fields on the current
* step. Without this, required fields on later steps (e.g. email/username
* on step 4) would block submission of earlier steps (e.g. a plan-only
* step 1). Mirrors the server-side logic in get_validation_rules() which
* filters rules to $this->step['fields'] for non-final steps.
*/
$step_fields = [];
if ($this->checkout_form) {
foreach ($this->checkout_form->get_steps_to_show() as $step) {
if ( ! empty($step['id']) && ! empty($step['fields'])) {
$field_ids = [];
foreach ($step['fields'] as $field) {
if ( ! empty($field['id'])) {
$field_ids[] = $field['id'];
}
if ('template_selection' === wu_get_isset($field, 'type')) {
$field_ids[] = 'template_id';
}
if ('pricing_table' === wu_get_isset($field, 'type')) {
$field_ids[] = 'products';
}
}
$step_fields[ $step['id'] ] = array_values(array_unique(array_filter($field_ids)));
}
}
}
$variables['step_fields'] = $step_fields;
/**
* Allow plugin developers to filter the pre-sets of a checkout page.
*
* Be careful, missing keys can completely break the checkout
* on the front-end.
*
* @since 2.0.0
* @param array $variables Localized variables.
* @param Checkout $checkout The checkout class.
* @return array The new variables array.
*/
return apply_filters('wu_get_checkout_variables', $variables, $this);
}
/**
* Returns true when the current checkout form has a password field
* configured with auto_generate_password enabled.
*
* Used to suppress client-side password validation rules when no
* password input is rendered.
*
* @since 2.0.20
* @return bool
*/
protected function form_has_auto_generate_password(): bool {
if ( ! $this->checkout_form) {
return false;
}
foreach ($this->checkout_form->get_settings() as $step) {
foreach (wu_get_isset($step, 'fields', []) as $field) {
if ('password' === wu_get_isset($field, 'type') && ! empty($field['auto_generate_password'])) {
return true;
}
}
}
return false;
}
/**
* Logs the customer in immediately after a successful checkout submission.
*
* When a password was collected by the form (standard flow) we call
* wp_signon() so WordPress performs its normal credential round-trip.
* When no password was collected — e.g. the form uses
* auto_generate_password or has no password field at all (the simple
* preset with email-only) — wp_signon() would receive an empty credential
* and silently fail, leaving the user logged-out and triggering the
* "You need to be logged in" error on the finish-checkout page. In that
* case we set the auth cookie directly, since the customer record was
* just created in this very request and the credential is not available.
*
* @since 2.6.0
* @return \WP_Error|null
*/
protected function login_customer_after_checkout() {
if (is_user_logged_in()) {
return;
}
wp_clear_auth_cookie();
// Remove the pending payment check action so the customer is not
// prompted to pay for the payment when they are already on the
// checkout page.
remove_action('wp_login', array(Payment_Manager::get_instance(), 'check_pending_payments'), 10);
$password = $this->request_or_session('password');
if ($password) {
$user_credentials = array(
'user_login' => $this->customer->get_username(),
'user_password' => $password,
);
// Sign in the user as if they used the login form.
$signed_in_user = wp_signon($user_credentials, is_ssl());
if (is_wp_error($signed_in_user)) {
$login_error_message = wp_strip_all_tags($signed_in_user->get_error_message());
if (empty($login_error_message)) {
$login_error_message = __('Unknown login error.', 'ultimate-multisite');
}
return new \WP_Error(
'checkout_login_failed',
sprintf(
/* translators: %s is the login failure message returned by WordPress or another plugin. */
__('We could not log you in automatically during checkout. Please try again or contact support before continuing. Login error: %s', 'ultimate-multisite'),
$login_error_message
)
);
}
return;
}
/*
* No password was collected (e.g. the form uses auto_generate_password
* or has no password field at all — the simple preset). Direct auth-cookie
* login is only safe for a user created by this exact checkout request.
*/
$user_id = $this->customer->get_user_id();
if ( ! $user_id) {
return;
}
if ((int) $user_id !== (int) $this->created_user_id) {
return new \WP_Error(
'checkout_login_denied',
__('We could not verify that this account was created during the current checkout. Please log in to continue.', 'ultimate-multisite')
);
}
$user = get_user_by('ID', $user_id);
if ( ! $user) {
return;
}
wp_set_auth_cookie($user_id, false, is_ssl());
do_action('wp_login', $user->user_login, $user);
}
/**
* Converts the PHP validation rules into a JS-friendly structure.
*
* Each rule string (e.g. "required|min:4|email") is parsed into an array of
* rule objects so the client-side validator can process them without
* duplicating the rule definitions.
*
* Only rules that can be meaningfully evaluated client-side are included.
* Server-only rules (unique_site, unique:\WP_User, products, country, state,
* city, site_template) are intentionally omitted — the AJAX call still
* validates those server-side.
*
* @since 2.1.0
* @return array>
*/
public function get_js_validation_rules(): array {
$raw_rules = $this->validation_rules();
/*
* When the checkout form uses auto-generated passwords, strip the
* password-related rules from the JS ruleset so the client-side
* validator does not block submission on a field that is never shown.
*/
if ($this->form_has_auto_generate_password()) {
unset($raw_rules['password'], $raw_rules['password_conf']);
}
/*
* Rules that require a database lookup or complex server-side logic.
* These are skipped for client-side validation.
*/
$server_only = [
'unique_site',
'site_template',
'products',
'country',
'state',
'city',
];
$js_rules = [];
foreach ($raw_rules as $field => $rule_string) {
if (empty($rule_string)) {
continue;
}
$parsed = [];
foreach (explode('|', $rule_string) as $rule_part) {
$rule_part = trim($rule_part);
if (empty($rule_part)) {
continue;
}
// Split "rule:param" into rule name and optional parameter.
if (strpos($rule_part, ':') !== false) {
[$rule_name, $rule_param] = explode(':', $rule_part, 2);
} else {
$rule_name = $rule_part;
$rule_param = null;
}
// Skip rules that start with "unique:" (DB lookups).
if (strpos($rule_name, 'unique') === 0) {
continue;
}
// Skip server-only rules.
if (in_array($rule_name, $server_only, true)) {
continue;
}
$parsed[] = [
'rule' => $rule_name,
'param' => $rule_param,
];
}
if ( ! empty($parsed)) {
$js_rules[ $field ] = $parsed;
}
}
/**
* Allow plugin developers to modify the JS validation rules.
*
* @since 2.1.0
* @param array $js_rules Field => parsed rule array.
* @param Checkout $checkout The checkout instance.
* @return array
*/
return apply_filters('wu_checkout_js_validation_rules', $js_rules, $this);
}
/**
* Determines whether payment should be collected for the current checkout.
*
* Uses $this->order if available, otherwise builds a temporary Cart
* from the request/session data to check.
*
* @since 2.0.20
* @return bool
*/
public function should_collect_payment(): bool {
if ($this->order) {
return $this->order->should_collect_payment();
}
$products = $this->request_or_session('products', []);
if (empty($products)) {
return true;
}
try {
$cart = new Cart(
[
'products' => (array) $products,
'country' => $this->request_or_session('billing_country'),
'discount_code' => $this->request_or_session('discount_code'),
'duration' => $this->request_or_session('duration'),
'duration_unit' => $this->request_or_session('duration_unit'),
'custom_amounts' => $this->request_or_session('custom_amounts', []),
'pwyw_recurring' => $this->request_or_session('pwyw_recurring', []),
]
);
return $cart->should_collect_payment();
} catch (\Throwable $e) {
return true;
}
}
/**
* Returns the validation rules for the fields.
*
* @todo The fields needs to declare this themselves.
*
* @since 2.0.0
* @return array
*/
public function validation_rules() {
/*
* Validations rules change
* depending on the type of order.
*
* For example, the only type that
* requires the site fields
* are the 'new'.
*
* First, let's set upm the general rules:
*/
/*
* When the password field is set to auto-generate, the customer does
* not submit a password value, so we must not require it.
*/
$auto_generate_password = $this->request_or_session('auto_generate_password');
$rules = [
'email_address' => 'required_without:user_id|email|unique:\WP_User,email',
'email_address_confirmation' => 'same:email_address',
'username' => 'required_without:user_id|alpha_dash|min:4|lowercase|unique:\WP_User,login',
'password' => $auto_generate_password ? '' : 'required_without:user_id|min:6',
'password_conf' => $auto_generate_password ? '' : 'same:password',
'template_id' => 'integer|site_template',
'products' => 'products',
'gateway' => '',
'billing_country' => 'country|required_with:billing_country',
'billing_zip_code' => 'required_with:billing_zip_code',
'billing_state' => 'state',
'billing_city' => 'city',
];
/*
* Add rules for site when creating a new account.
*/
if ('new' === $this->type) {
// char limit according https://datatracker.ietf.org/doc/html/rfc1034#section-3.1
$rules['site_title'] = 'min:4';
$rules['site_url'] = 'min:3|max:63|lowercase|unique_site';
}
return apply_filters('wu_checkout_validation_rules', $rules, $this);
}
/**
* Returns the list of validation rules.
*
* If we are dealing with a step submission, we will return
* only the validation rules that refer to the keys sent via POST.
*
* If this is the submission of the last step, though, we return all
* validation rules so we can validate the entire signup.
*
* @since 2.0.0
* @return array
*/
public function get_validation_rules() {
$validation_rules = $this->validation_rules();
if (wu_request('pre-flight')) {
$validation_rules = [];
return $validation_rules;
}
if ($this->step_name && $this->is_last_step() === false) {
$fields_available = array_column($this->step['fields'], 'id');
/*
* Re-adds the template id check
*/
if (wu_request('template_id', null) !== null) {
$fields_available[] = 'template_id';
}
$validation_rules = array_filter($validation_rules, fn($rule) => in_array($rule, $fields_available, true), ARRAY_FILTER_USE_KEY);
}
// We'll use this to validate product fields
$product_fields = [
'pricing_table',
'products',
];
/*
* Maps checkout field IDs to their corresponding validation-rule
* keys when the two differ. For example the "template_selection"
* signup field submits its value as "template_id" in the POST data,
* so a `required` attribute on that field must target the
* "template_id" rule — not "template_selection".
*/
$field_to_rule_key = [
'template_selection' => 'template_id',
];
$billing_address_required = false;
/**
* Add the additional required fields.
*/
foreach ($this->step['fields'] as $field) {
/*
* General required fields
*/
if (wu_get_isset($field, 'required') && wu_get_isset($field, 'id')) {
if ('billing_address' === $field['id']) {
$billing_address_required = true;
foreach (['billing_country', 'billing_zip_code'] as $billing_rule_key) {
if (isset($validation_rules[ $billing_rule_key ])) {
$validation_rules[ $billing_rule_key ] .= '|required';
}
}
continue;
}
$rule_key = $field_to_rule_key[ $field['id'] ] ?? $field['id'];
if (isset($validation_rules[ $rule_key ])) {
$validation_rules[ $rule_key ] .= '|required';
} else {
$validation_rules[ $rule_key ] = 'required';
}
/*
* For template_id the `required` rule alone is not enough
* because Rakit considers integer 0 as "present". Add min:1
* so the checkout rejects template_id=0 when the form
* includes a required template selection field.
*
* Admin/network site creation does not go through checkout
* validation, so blank (no-template) sites can still be
* created from the network admin.
*/
if ('template_id' === $rule_key) {
$validation_rules[ $rule_key ] .= '|min:1';
}
}
/*
* Product fields
*/
if (wu_get_isset($field, 'id') && in_array($field['id'], $product_fields, true)) {
$validation_rules['products'] = 'products|required';
}
}
/*
* Relax the base billing rules for billing-address fields rendered by an
* optional billing-address element. The base ZIP/Country rules use
* required_with:, and Rakit treats an empty submitted field as
* "present", so optional visible fields would still fail validation.
*/
$submitted_billing_country = trim((string) wu_request('billing_country', ''));
$optional_billing_rules = [
'billing_country' => '' === $submitted_billing_country ? '' : 'country',
'billing_zip_code' => '',
];
$billing_rule_fields = $this->checkout_form ? $this->checkout_form->get_all_fields() : $this->step['fields'];
$has_optional_billing_address = false;
foreach ($billing_rule_fields as $field_key => $field) {
if ( ! is_array($field)) {
continue;
}
$field_id = wu_get_isset($field, 'id', is_string($field_key) ? $field_key : '');
/*
* Checkout_Form::get_all_fields() returns the configured composite
* field, rather than the billing_country and billing_zip_code fields
* generated when it is rendered.
*/
if ('billing_address' === $field_id && ! wu_get_isset($field, 'required')) {
$has_optional_billing_address = true;
continue;
}
if (isset($optional_billing_rules[ $field_id ]) && ! wu_get_isset($field, 'required')) {
$validation_rules[ $field_id ] = $optional_billing_rules[ $field_id ];
}
}
if ($has_optional_billing_address) {
foreach ($optional_billing_rules as $field_id => $rule) {
$validation_rules[ $field_id ] = $rule;
}
}
/*
* Relax billing field requirements when payment is not needed
* (e.g. free trials with allow_trial_without_payment_method enabled).
* Country is kept required for tax calculation at renewal time.
*/
if ( ! $this->should_collect_payment() && ! $billing_address_required) {
$validation_rules['billing_zip_code'] = '';
$validation_rules['billing_state'] = '';
$validation_rules['billing_city'] = '';
}
/*
* Skip the billing_zip_code rule when the selected country does not
* use postal codes. The Vue layer already removes the field from
* the DOM in that case, but a defensive server-side check protects
* against clients (REST, custom forms, automated tests) that bypass
* the v-if hiding.
*/
$submitted_country = trim((string) wu_request('billing_country', ''));
if ('' === $submitted_country) {
$submitted_country = trim((string) wu_request('country', ''));
}
if ($submitted_country && isset($validation_rules['billing_zip_code'])) {
$resolved_country = wu_get_country($submitted_country);
if ($resolved_country && ! $resolved_country->get_uses_postal_code()) {
$validation_rules['billing_zip_code'] = '';
}
}
/**
* Allow plugin developers to filter the validation rules.
*
* @since 2.0.20
* @param array $validation_rules The validation rules to be used.
* @param Checkout $checkout The checkout class.
*/
return apply_filters('wu_checkout_validation_rules', $validation_rules, $this);
}
/**
* Validates the rules and make sure we only save models when necessary.
*
* @since 2.0.0
* @param array $rules Custom rules to use instead of the default ones.
* @return true|\WP_Error
*/
public function validate($rules = null) {
$validator = new \WP_Ultimo\Helpers\Validator();
$session = $this->session->get('signup');
// Nonce check handled in calling method.
if (is_array($session)) {
$stack = array_merge($session, $_REQUEST); // phpcs:ignore WordPress.Security.NonceVerification
} else {
$stack = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification
}
if (null === $rules) {
$rules = $this->get_validation_rules();
}
$base_aliases = [];
$checkout_form_fields = $this->checkout_form ? $this->checkout_form->get_all_fields() : [];
// Add current form fields
foreach ($checkout_form_fields as $field) {
$base_aliases[ $field['id'] ] = wu_get_isset($field, 'name', '');
}
// Add Billing Address fields
foreach (Billing_Address::fields() as $field_key => $field) {
$base_aliases[ $field_key ] = wu_get_isset($field, 'title', '');
}
// Add some hidden or compound fields ids
$validation_aliases = array_merge(
[
'password_conf' => __('Password confirmation', 'ultimate-multisite'),
'email_address_confirmation' => __('Email confirmation', 'ultimate-multisite'),
'template_id' => __('Template Selection', 'ultimate-multisite'),
'products' => __('Products', 'ultimate-multisite'),
'gateway' => __('Payment Gateway', 'ultimate-multisite'),
],
$base_aliases
);
/**
* Allow plugin developers to add custom aliases in form validator.
*
* @since 2.1
* @param array $validation_aliases The array with id => alias.
* @param Checkout $checkout The checkout class.
*/
$validation_aliases = apply_filters('wu_checkout_validation_aliases', $validation_aliases, $this);
$validator->validate($stack, $rules, $validation_aliases);
if ($validator->fails()) {
return $validator->get_errors();
}
return true;
}
/**
* Decides if we are to process a checkout.
*
* Needs to decide if we are simply putting the customer through the next step
* or if we need to actually process the checkout.
* It checks of the current checkout is multi-step;
* If it is, process info, save into session and send to the next step.
* Otherwise, we process the checkout.
*
* @since 2.0.0
* @return void
*/
public function maybe_process_checkout(): void {
/*
* Sets up the checkout
* environment.
*/
$this->setup_checkout();
/*
* Checks if we should be here.
* We can only process a checkout
* if certain conditions are met.
*/
if ( ! $this->should_process_checkout()) {
return;
}
/*
* Checks if we are in the last step.
*
* Ultimate Multisite supports multi-step checkout
* flows. That means that we do different
* things on the intermediary steps (mostly
* add things to the session) and on the final,
* where we process the checkout.
*
* Let's deal with the last step case first.
*/
if ($this->is_last_step()) {
/*
* We are in the last step and
* we can process the checkout normally.
*/
$results = $this->process_checkout();
/*
* Error!
*
* We redirect the customer back to the
* checkout page, passing the payment query
* arg so the customer can try again.
*/
if (is_wp_error($results)) {
$redirect_url = wu_get_current_url();
$this->session->set('errors', $results);
/*
* We attach the payment data
* to the error, so we can retrieve it here.
*/
$payment = wu_get_isset($results->get_error_data(), 'payment');
/*
* If the payment exists,
* use the hash to redirect the customer
* to a try again page.
*/
if ($payment) {
$redirect_url = add_query_arg(
[
'payment' => $payment->get_hash(),
'status' => 'error',
],
$redirect_url
);
}
/*
* Redirect go burrr!
*/
wp_safe_redirect($redirect_url);
exit;
}
/*
* This is not the final step,
* so we just clean the data and save it
* for later.
*/
} else {
$this->persist_current_step_to_session();
/**
* Whether we should advance to the next step.
* This prevents breaking the checkout flow when triggered from a shortcode page.
*/
if ( ! wu_request('pre-flight')) {
/*
* Go to the next step.
*/
$next_step = $this->get_next_step_name();
wp_safe_redirect(add_query_arg('step', $next_step));
exit;
}
}
}
/**
* Runs pre-checks to see if we should process the checkout.
*
* @since 2.0.0
* @return boolean
*/
public function should_process_checkout() {
return wu_request('checkout_action') === 'wu_checkout' && ! wp_doing_ajax();
}
/**
* Handles the checkout submission.
*
* @since 2.0.0
* @return mixed
*/
public function process_checkout() {
/**
* Before we process the checkout.
*
* @since 2.0.11
* @param Checkout $checkout The current checkout instance;
*/
do_action('wu_checkout_before_process_checkout', $this);
$this->setup_checkout();
$gateway = wu_get_gateway(wu_request('gateway'));
$payment = wu_get_payment($this->request_or_session('payment_id'));
if ( ! $payment) {
// translators: %s payment id.
$this->errors = new \WP_Error('no-payment', sprintf(__('Payment (%s) not found.', 'ultimate-multisite'), $this->request_or_session('payment_id')));
return false;
}
$customer = $payment->get_customer();
$membership = $payment->get_membership();
/**
* Get the original cart from saved payment meta so we can finish the process.
* It ensure that the cart is the same used in beginning of the process.
*/
$this->order = $payment->get_meta('wu_original_cart');
/*
* The original cart is only stored for payments created through the
* normal checkout flow (process_order). Payments created elsewhere
* (webhooks, admin, the register API) have no 'wu_original_cart' meta,
* so get_meta() returns its `false` default. Bail cleanly instead of
* fatally calling a method on a boolean.
*/
if ( ! ($this->order instanceof \WP_Ultimo\Checkout\Cart)) {
$this->errors = new \WP_Error('no-cart', __('This checkout session has expired or cannot be resumed. Please start over.', 'ultimate-multisite'));
return false;
}
$this->order->set_membership($membership);
$this->order->set_customer($customer);
$this->order->set_payment($payment);
try {
/*
* We need to handle free payments and trials w/o cc separately.
*
* In the same manner, if the order
* IS NOT free, we need to make sure
* the customer is not trying to game the system
* passing the free gateway to get an free account.
*
* That's what's we checking on the else case.
*/
if ($payment->get_status() === Payment_Status::COMPLETED) {
$gateway = wu_get_gateway($payment->get_gateway());
} elseif ($this->order->should_collect_payment() === false) {
$gateway = wu_get_gateway('free');
} elseif ( ! $gateway || $gateway->get_id() === 'free' || ! array_key_exists($gateway->get_id(), wu_get_active_gateway_as_options())) {
$this->errors = new \WP_Error('no-gateway', __('Payment gateway not registered.', 'ultimate-multisite'));
return false;
}
if ( ! $gateway) {
$this->errors = new \WP_Error('no-gateway', __('Payment gateway not registered.', 'ultimate-multisite'));
return false;
}
/*
* Set the gateway data.
*/
$gateway->set_order($this->order);
/*
* Let's grab the cart type.
* We'll use it to perform the necessary actions
* with memberships, payments, and such.
*/
$type = $this->order->get_cart_type();
/*
* Here's where the action actually happens.
*
* The gateway takes in the info about the transaction
* and perform the necessary steps to make sure the
* data on the gateway correctly reflects the data on Ultimate Multisite.
*/
$status = $gateway->process_checkout($payment, $membership, $customer, $this->order, $type);
/*
* If the gateway returns a explicit false value
* we understand that as a signal that the gateway wants to
* deal with the modifications by itself.
*
* In that case, we simply return.
*/
if (false === $status) {
return true;
}
/*
* Run after every checkout processing.
*
* @since 2.0.4
*/
do_action('wu_checkout_done', $payment, $membership, $customer, $this->order, $type, $this);
/*
* Deprecated hook for registration.
*/
if (has_action('wp_ultimo_registration')) {
$_payment = wu_get_payment($payment->get_id());
$args = [
0, // Site ID is not yet available at this point
$customer->get_user_id(),
$this->session->get('signup'),
$_payment && $_payment->get_membership() ? new \WU_Plan($_payment->get_membership()->get_plan()) : false,
];
ob_start();
do_action_deprecated('wp_ultimo_registration', $args, '2.0.0');
ob_flush();
}
/*
* Otherwise, we redirect
* to the thank you page
* of the front-end mode.
*/
$redirect_url = $gateway->get_return_url();
if ( ! is_admin()) {
/**
* Set the redirect URL.
*
* This is a legacy filter. Some of the parameters
* passed are not available, such as the $site_id.
*
* @since 1.1.3 Let developers filter the redirect URL.
*/
$redirect_url = apply_filters('wp_ultimo_redirect_url_after_signup', $redirect_url, 0, get_current_user_id());
$redirect_url = add_query_arg(
[
'payment' => $payment ? $payment->get_hash() : 'none',
'status' => 'done',
],
$redirect_url
);
// Land on the thank-you block (and its progress bar), not the page header.
if (false === strpos($redirect_url, '#')) {
$redirect_url .= '#wu-thank-you-element';
}
}
wp_safe_redirect($redirect_url);
exit;
} catch (\Throwable $e) {
$membership_id = $this->order->get_membership() ? $this->order->get_membership()->get_id() : 'unknown';
// translators: %s is the membership ID
$log_message = sprintf(__('Checkout failed for customer %s: ', 'ultimate-multisite'), $membership_id);
$log_message .= $e->getMessage();
wu_log_add('checkout', $log_message, LogLevel::ERROR);
return new \WP_Error(
'error',
$e->getMessage(),
[
'trace' => $e->getTrace(),
'payment' => $payment,
]
);
}
}
/**
* Handle user display names, if first and last names are available.
*
* @since 2.0.4
*
* @param string $display_name The current display name.
* @return string
*/
public function handle_display_name($display_name) {
$first_name = $this->request_or_session('first_name', '');
$last_name = $this->request_or_session('last_name', '');
if ($first_name || $last_name) {
$display_name = trim("$first_name $last_name");
}
return $display_name;
}
/*
* Helper methods
*
* These mostly deal with
* multi-step checkout control
* and can be mostly ignored!
*/
/**
* Get thank you page URL.
*
* @since 2.0.0
* @return string
*/
public function get_thank_you_page() {
return wu_get_current_url();
}
/**
* Checks if the user already exists.
*
* @since 2.0.0
* @return boolean
*/
public function is_existing_user() {
return is_user_logged_in();
}
/**
* Returns the customer email verification status we want to use depending on the type of checkout.
*
* @since 2.0.0
* @return string
*/
public function get_customer_email_verification_status() {
$email_verification_setting = wu_get_setting('enable_email_verification', 'free_only');
switch ($email_verification_setting) {
case 'never':
return 'none';
case 'always':
return 'pending';
case 'free_only':
return $this->order->should_collect_payment() === false ? 'pending' : 'none';
default:
// Legacy behavior - handle boolean values
$should_confirm_email = (bool) $email_verification_setting;
return $this->order->should_collect_payment() === false && $should_confirm_email ? 'pending' : 'none';
}
}
/**
* Adds the checkout scripts.
*
* @see $this->get_checkout_variables()
*
* @since 2.0.0
* @return void
*/
public function register_scripts(): void {
$custom_css = apply_filters('wu_checkout_custom_css', '');
if ($custom_css) {
wp_add_inline_style('wu-checkout', $custom_css);
}
wp_enqueue_style('wu-checkout');
wp_enqueue_style('wu-admin');
// Enqueue password styles (includes dashicons as dependency).
wp_enqueue_style('wu-password');
$script_dependencies = ['jquery-core', 'wu-vue', 'moment', 'wu-block-ui', 'wu-functions', 'password-strength-meter', 'wu-password-strength', 'underscore', 'wp-polyfill', 'wp-hooks', 'wp-i18n', 'wu-cookie-helpers', 'wu-password-toggle'];
$passwordless_auth = \WP_Ultimo\Auth\Passwordless_Auth_Manager::get_instance();
if ($passwordless_auth->is_enabled()) {
$passwordless_auth->enqueue_assets();
$script_dependencies[] = 'wu-passwordless-auth';
}
wp_register_script('wu-checkout', wu_get_asset('checkout.js', 'js'), $script_dependencies, wu_get_version(), true);
wp_set_script_translations('wu-password-toggle', 'ultimate-multisite');
wp_localize_script('wu-checkout', 'wu_checkout', $this->get_checkout_variables());
wp_enqueue_script('wu-checkout');
}
/**
* Gets the info either from the request or session.
*
* We try to get the key from the session object, but
* if that doesn't work or it doesn't exist, we try
* to get it from the request instead.
*
* @since 2.0.0
*
* @param string $key Key to retrieve the value for.
* @param mixed $default_value The default value to return, when nothing is found.
* @return mixed
*/
public function request_or_session($key, $default_value = false) {
$value = $default_value;
if (null !== $this->session) {
$session = $this->session->get('signup');
if (isset($session[ $key ])) {
$value = $session[ $key ];
}
}
$value = wu_request($key, $value);
return $value;
}
/**
* Returns the name of the next step on the flow.
*
* @since 2.0.0
* @return string
*/
public function get_next_step_name() {
$steps = $this->get_steps_or_empty_array();
$keys = array_column($steps, 'id');
if (empty($keys)) {
return $this->step_name;
}
$current_step_index = array_search($this->step_name, array_values($keys), true);
/*
* If we enter the if statement below,
* it means that we don't have a step name set
* so we need to set it to the first.
*/
if (false === $current_step_index) {
$current_step_index = 0;
}
$index = $current_step_index + 1;
return $keys[ $index ] ?? $keys[ $current_step_index ];
}
/**
* Checks if we are in the first step of the signup.
*
* @since 2.0.0
* @return boolean
*/
public function is_first_step() {
$step_names = array_column($this->get_steps_or_empty_array(), 'id');
if (empty($step_names)) {
return true;
}
return array_shift($step_names) === $this->step_name;
}
/**
* Checks if we are in the last step of the signup.
*
* @since 2.0.0
* @return boolean
*/
public function is_last_step() {
/**
* What is this pre-flight parameter, you may ask...
*
* Well, some shortcodes can jump-start the signup process
* for example, the pricing table shortcode allows the
* customers to select a plan.
*
* This submits the form inside the shortcode to the registration
* page, but if that page is a one-step signup, this class-checkout
* will deal with it as if it was a last-step submission.
*
* The presence of the pre-flight URL parameter prevents that
* from happening.
*
* The summary is: if you need to post info to the registration page
* you need to add the ?pre-flight to the action URL.
*/
if (wu_request('pre-flight')) {
return false;
}
$step_names = array_column($this->get_steps_or_empty_array(), 'id');
if (empty($step_names)) {
return true;
}
return array_pop($step_names) === $this->step_name;
}
/**
* Returns checkout steps as an array.
*
* Payment return and thank-you requests can enqueue checkout scripts after the
* checkout form context has been cleared, leaving the public steps property
* unset/null. Treat that state as an empty one-step flow instead of fataling
* when navigation helpers call array_column().
*
* @since 2.13.2
* @return array
*/
protected function get_steps_or_empty_array() {
return is_array($this->steps) ? $this->steps : [];
}
/**
* Decides if we should display errors on the checkout screen.
*
* @since 2.0.0
* @return void
*/
public function maybe_display_checkout_errors(): void {
if (wu_request('status') !== 'error') {
return;
}
$message = wu_request('wu_error_msg');
if (empty($message)) {
return;
}
$message = sanitize_text_field(rawurldecode($message));
printf(
'%s
',
esc_html($message)
);
}
/**
* Cleans up draft and pending payments strictly older than 30 days.
*
* When a pending payment is cancelled, the associated membership is also
* cancelled if it is still in the `pending` state. This ensures that any
* `pending_site` record stored in membership meta is cleaned up via the
* `wu_transition_membership_status` → `handle_pending_site_on_cancellation`
* chain, preventing orphaned pending_site rows from accumulating in the
* database. Fixes: GH#982.
*
* @since 2.1.4
* @return void
*/
public function cleanup_expired_drafts(): void {
$expired_date_query = [
'column' => 'date_created',
'before' => '-30 days',
'inclusive' => false,
];
$expired_drafts = wu_get_payments(
[
'status' => Payment_Status::DRAFT,
'date_query' => $expired_date_query,
]
);
$expired_pendings = wu_get_payments(
[
'status' => Payment_Status::PENDING,
'date_query' => $expired_date_query,
]
);
foreach (array_merge($expired_drafts, $expired_pendings) as $payment) {
if ($payment->get_status() === Payment_Status::PENDING) {
$payment->set_status(Payment_Status::CANCELLED);
$payment->save();
/*
* Also cancel the associated membership if it is still in
* `pending` state. An unconfirmed payment older than 30 days
* means the customer did not complete the signup; keeping the
* membership in `pending` would leave any pending_site meta
* orphaned because no active membership owns it. Cancelling via
* cancel() fires wu_transition_membership_status, which
* invokes handle_pending_site_on_cancellation() to move the
* pending_site to a 24-hour transient for potential reclaim
* before deleting it from membership meta. GH#982.
*/
$membership = $payment->get_membership();
if ($membership && Membership_Status::PENDING === $membership->get_status()) {
$membership->cancel();
}
} else {
$payment->delete();
}
}
}
/**
* Handles cancel payment requests.
*
* @since 2.1.4
* @return void
*/
public function handle_cancel_payment(): void {
$payment_id = wu_request('cancel_payment');
if (! $payment_id) {
return;
}
if (! wp_verify_nonce(wu_request('_wpnonce'), 'cancel_payment_' . $payment_id)) {
return;
}
$payment = wu_get_payment($payment_id);
if (! $payment || $payment->get_status() !== Payment_Status::PENDING) {
return;
}
if (! $this->can_user_cancel_payment($payment)) {
return;
}
$payment->set_status(Payment_Status::CANCELLED);
$payment->save();
// Redirect back
wp_safe_redirect(remove_query_arg(['cancel_payment', '_wpnonce']));
exit;
}
/**
* Creates a draft payment for incomplete checkouts.
*
* @since 2.1.4
*
* @param array $products List of products.
* @return void
*/
protected function create_draft_payment($products): void {
$cart = new Cart(
[
'products' => $products,
'cart_type' => 'new',
'duration' => $this->request_or_session('duration'),
'duration_unit' => $this->request_or_session('duration_unit'),
]
);
if ($cart->is_valid() === false) {
return; // Don't create draft if cart is invalid
}
$payment_data = $cart->to_payment_data();
$payment_data['status'] = Payment_Status::DRAFT;
$payment = wu_create_payment($payment_data);
if (is_wp_error($payment)) {
// Log the error but allow checkout to continue
wu_log_add(
'checkout',
sprintf(
'Failed to create draft payment: %s (Code: %s)',
$payment->get_error_message(),
$payment->get_error_code()
),
\Psr\Log\LogLevel::WARNING
);
return;
}
if ($payment) {
$this->session->set('draft_payment_id', $payment->get_id());
$payment->update_meta('checkout_session', $this->session->get());
$this->session->commit();
}
}
/**
* Returns the current step values that should be stored in the signup session.
*
* Checkout control fields and private nonce fields are intentionally excluded
* so only customer-entered checkout data is carried across steps.
*
* @since 2.6.0
* @return array
*/
protected function get_current_step_session_values() {
return array_filter(
$_POST, // phpcs:ignore WordPress.Security.NonceVerification.Missing
fn($key) => ! str_starts_with((string) $key, 'checkout_') && ! str_starts_with((string) $key, '_'),
ARRAY_FILTER_USE_KEY
);
}
/**
* Persists the current checkout step values in the signup session.
*
* This is used by both the AJAX validation pass and the subsequent native
* step-advance POST. Saving during AJAX prevents first-step account fields
* from being absent when a later step performs final AJAX validation.
*
* @since 2.6.0
* @return array Values stored for the current step.
*/
protected function persist_current_step_to_session() {
if (null === $this->session) {
$this->session = wu_get_session('signup');
}
$to_save = $this->get_current_step_session_values();
$needs_commit = false;
if (isset($to_save['pre-flight'])) {
unset($to_save['pre-flight']);
$this->session->add_values('signup', ['pre_selected' => $to_save]);
$needs_commit = true;
}
if ( ! empty($to_save)) {
$this->session->add_values('signup', $to_save);
$needs_commit = true;
}
if ($needs_commit) {
$this->session->commit();
}
return $to_save;
}
/**
* Saves the current checkout progress to the draft payment.
*
* @since 2.1.4
* @return void
*/
protected function save_draft_progress(): void {
$draft_payment_id = $this->session->get('draft_payment_id');
if (! $draft_payment_id) {
return;
}
$draft_payment = wu_get_payment($draft_payment_id);
if (! $draft_payment || $draft_payment->get_status() !== Payment_Status::DRAFT) {
return;
}
$draft_payment->update_meta('checkout_session', $this->session->get());
}
/**
* Checks if the current user can cancel a payment.
*
* @since 2.1.4
*
* @param \WP_Ultimo\Models\Payment $payment The payment object.
* @return bool
*/
protected function can_user_cancel_payment($payment): bool {
if (! is_user_logged_in()) {
return false;
}
$customer = wu_get_current_customer();
return $customer && $customer->get_id() === $payment->get_customer_id();
}
}