$this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );
if ( $result ) {
// Record an event for the step being completed.
$this->record_event(
self::EVENT_PREFIX . 'onboarding_step_completed',
$location,
array(
'step_id' => $step_id,
)
);
}
return $result;
}
/**
* Cleans an onboarding step progress.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return bool Whether the onboarding step was cleaned.
* @throws ApiArgumentException If the given onboarding step ID is invalid.
* @throws ApiException If the onboarding action can not be performed due to the current state of the site.
*/
public function clean_onboarding_step_progress( string $step_id, string $location ): bool {
$this->check_if_onboarding_step_action_is_acceptable( $step_id, $location );
// Clear possible failed or blocked status for the step.
$this->clear_onboarding_step_failed( $step_id, $location );
$this->clear_onboarding_step_blocked( $step_id, $location );
// Reset the stored step statuses.
$result = $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', array() );
if ( $result ) {
// Record an event for the step being cleaned.
$this->record_event(
self::EVENT_PREFIX . 'onboarding_step_progress_reset',
$location,
array(
'step_id' => $step_id,
)
);
}
return $result;
}
/**
* Check if an onboarding step has a failed status.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return bool Whether the onboarding step is failed.
*/
private function is_onboarding_step_failed( string $step_id, string $location ): bool {
$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
return ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_FAILED ] );
}
/**
* Mark an onboarding step as failed.
*
* This is for internal use only as a failed step status should not be the result of a user action.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param array $error Optional. An error to be stored for the step to provide context to API consumers.
* The error should be an associative array with the following keys:
* - 'code': A string representing the error code.
* - 'message': A string representing the error message.
* - 'context': Optional. An array of additional data related to the error.
*
* @return bool Whether the onboarding step was marked as failed.
*/
private function mark_onboarding_step_failed( string $step_id, string $location, array $error = array() ): bool {
// There is no need to do onboarding checks because setting a step as failed should be possible at any time.
// Record the error for the step, even if it is empty.
// This will ensure we only store the most recent error.
$this->save_nox_profile_onboarding_step_data_entry( $step_id, $location, 'error', $this->sanitize_onboarding_step_error( $error ) );
$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
// Mark the step as failed and record the timestamp.
$statuses[ self::ONBOARDING_STEP_STATUS_FAILED ] = $this->proxy->call_function( 'time' );
// Make sure we clear the blocked status if it was set since blocked and failed should be mutually exclusive.
unset( $statuses[ self::ONBOARDING_STEP_STATUS_BLOCKED ] );
// Store the updated step data.
$result = $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );
if ( $result ) {
// Record an event for the step being failed.
$this->record_event(
self::EVENT_PREFIX . 'onboarding_step_failed',
$location,
array(
'step_id' => $step_id,
'error_code' => ! empty( $error['code'] ) ? $error['code'] : '',
)
);
}
return $result;
}
/**
* Clear the failed status of an onboarding step.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return bool Whether the onboarding step was cleared from failed status.
* Returns false if the step was not failed.
*/
private function clear_onboarding_step_failed( string $step_id, string $location ): bool {
if ( ! $this->is_onboarding_step_failed( $step_id, $location ) ) {
return false;
}
// Clear any error for the step.
$this->save_nox_profile_onboarding_step_data_entry( $step_id, $location, 'error', array() );
$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
// Clear the failed status.
unset( $statuses[ self::ONBOARDING_STEP_STATUS_FAILED ] );
// Store the updated step data.
return $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );
}
/**
* Check if an onboarding step has a blocked status.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return bool Whether the onboarding step is blocked.
*/
private function is_onboarding_step_blocked( string $step_id, string $location ): bool {
$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
return ! empty( $statuses[ self::ONBOARDING_STEP_STATUS_BLOCKED ] );
}
/**
* Mark an onboarding step as blocked.
*
* This is for internal use only as a blocked step status should not be the result of a user action.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param array $errors Optional. A list of errors to be stored for the step to provide context to API consumers.
*
* @return bool Whether the onboarding step was marked as blocked.
*/
private function mark_onboarding_step_blocked( string $step_id, string $location, array $errors = array() ): bool {
// There is no need to do onboarding checks because setting a step as blocked should be possible at any time.
// Record the error for the step, even if it is empty.
// This will ensure we only store the most recent error.
$this->save_nox_profile_onboarding_step_data_entry( $step_id, $location, 'error', $this->sanitize_onboarding_step_error( $errors ) );
$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
// Mark the step as blocked and record the timestamp.
$statuses[ self::ONBOARDING_STEP_STATUS_BLOCKED ] = $this->proxy->call_function( 'time' );
// Make sure we clear the failed status if it was set since blocked and failed should be mutually exclusive.
unset( $statuses[ self::ONBOARDING_STEP_STATUS_FAILED ] );
// Store the updated step data.
return $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );
}
/**
* Clear the blocked status of an onboarding step.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return bool Whether the onboarding step was cleared from blocked status.
* Returns false if the step was not blocked.
*/
private function clear_onboarding_step_blocked( string $step_id, string $location ): bool {
if ( ! $this->is_onboarding_step_blocked( $step_id, $location ) ) {
return false;
}
// Clear any error for the step.
$this->save_nox_profile_onboarding_step_data_entry( $step_id, $location, 'error', array() );
$statuses = (array) $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses' );
// Clear the blocked status.
unset( $statuses[ self::ONBOARDING_STEP_STATUS_BLOCKED ] );
// Store the updated step data.
return $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'statuses', $statuses );
}
/**
* Get the current stored error for an onboarding step.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return array The error for the onboarding step.
*/
private function get_onboarding_step_error( string $step_id, string $location ): array {
return (array) $this->get_nox_profile_onboarding_step_data_entry( $step_id, $location, 'error', array() );
}
/**
* Sanitize an error for an onboarding step.
*
* @param array $error The error to sanitize.
*
* @return array The sanitized error.
*/
private function sanitize_onboarding_step_error( array $error ): array {
$sanitized_error = array(
'code' => isset( $error['code'] ) ? sanitize_text_field( $error['code'] ) : '',
'message' => isset( $error['message'] ) ? sanitize_text_field( $error['message'] ) : '',
'context' => array(),
);
if ( isset( $error['context'] ) && ( is_array( $error['context'] ) || is_object( $error['context'] ) ) ) {
// Make sure we are dealing with an array.
$sanitized_error['context'] = json_decode( wp_json_encode( $error['context'] ), true );
if ( ! is_array( $sanitized_error['context'] ) ) {
$sanitized_error['context'] = array();
}
// Sanitize the context data.
// It can only contain strings or arrays of strings.
// Scalar values will be converted to strings. Other types will be ignored.
foreach ( $sanitized_error['context'] as $key => $value ) {
if ( is_string( $value ) ) {
$sanitized_error['context'][ $key ] = sanitize_text_field( $value );
} elseif ( is_array( $value ) ) {
// Arrays can only contain strings.
$sanitized_error['context'][ $key ] = array_map(
function ( $item ) {
if ( is_string( $item ) ) {
return sanitize_text_field( $item );
} elseif ( is_scalar( $item ) ) {
return sanitize_text_field( (string) $item );
} else {
return '';
}
},
$value
);
// Remove any empty values from the array.
$sanitized_error['context'][ $key ] = array_filter(
$sanitized_error['context'][ $key ],
function ( $item ) {
return '' !== $item;
}
);
} else {
unset( $sanitized_error['context'][ $key ] );
}
}
}
return $sanitized_error;
}
/**
* Save the data for an onboarding step.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param array $request_data The entire data received in the request.
*
* @return bool Whether the onboarding step data was saved.
* @throws ApiArgumentException If the given onboarding step ID or step data is invalid.
* @throws ApiException If the onboarding action can not be performed due to the current state of the site.
*/
public function onboarding_step_save( string $step_id, string $location, array $request_data ): bool {
$this->check_if_onboarding_step_action_is_acceptable( $step_id, $location );
// Validate the received step data.
// If we didn't receive any known data for the step, we consider it an invalid save operation.
if ( ! $this->is_valid_onboarding_step_data( $step_id, $request_data ) ) {
throw new ApiArgumentException(
'woocommerce_woopayments_onboarding_invalid_step_data',
esc_html__( 'Invalid onboarding step data.', 'woocommerce' ),
(int) WP_Http::BAD_REQUEST
);
}
$step_details = $this->get_nox_profile_onboarding_step( $step_id, $location );
if ( empty( $step_details['data'] ) ) {
$step_details['data'] = array();
}
// Extract the data for the step.
switch ( $step_id ) {
case self::ONBOARDING_STEP_PAYMENT_METHODS:
if ( isset( $request_data['payment_methods'] ) ) {
$step_details['data']['payment_methods'] = $request_data['payment_methods'];
}
break;
case self::ONBOARDING_STEP_BUSINESS_VERIFICATION:
if ( isset( $request_data['self_assessment'] ) ) {
$step_details['data']['self_assessment'] = $request_data['self_assessment'];
}
if ( isset( $request_data['sub_steps'] ) ) {
$step_details['data']['sub_steps'] = $request_data['sub_steps'];
}
break;
default:
throw new ApiException(
'woocommerce_woopayments_onboarding_step_action_not_supported',
esc_html__( 'Save action not supported for the onboarding step ID.', 'woocommerce' ),
(int) WP_Http::NOT_ACCEPTABLE
);
}
// Store the updated step data.
return $this->save_nox_profile_onboarding_step( $step_id, $location, $step_details );
}
/**
* Check if the given onboarding step data is valid.
*
* If we didn't receive any known data for the step, we consider it invalid.
*
* @param string $step_id The ID of the onboarding step.
* @param array $request_data The entire data received in the request.
*
* @return bool Whether the given onboarding step data is valid.
*/
private function is_valid_onboarding_step_data( string $step_id, array $request_data ): bool {
switch ( $step_id ) {
case self::ONBOARDING_STEP_PAYMENT_METHODS:
// Check that we have at least one piece of data.
if ( ! isset( $request_data['payment_methods'] ) ) {
return false;
}
// Check that the data is in the expected format.
if ( ! is_array( $request_data['payment_methods'] ) ) {
return false;
}
break;
case self::ONBOARDING_STEP_BUSINESS_VERIFICATION:
// Check that we have at least one piece of data.
if ( ! isset( $request_data['self_assessment'] ) &&
! isset( $request_data['sub_steps'] ) ) {
return false;
}
// Check that the data is in the expected format.
if ( isset( $request_data['self_assessment'] ) && ! is_array( $request_data['self_assessment'] ) ) {
return false;
}
if ( isset( $request_data['sub_steps'] ) && ! is_array( $request_data['sub_steps'] ) ) {
return false;
}
break;
default:
// If we don't know how to validate the data, we assume it is valid.
return true;
}
return true;
}
/**
* Check an onboarding step's status/progress.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return array The check result.
* @throws ApiArgumentException If the given onboarding step ID or step data is invalid.
* @throws ApiException If the onboarding action can not be performed due to the current state of the site.
*/
public function onboarding_step_check( string $step_id, string $location ): array {
$this->check_if_onboarding_step_action_is_acceptable( $step_id, $location );
return array(
'status' => $this->get_onboarding_step_status( $step_id, $location ),
'error' => $this->get_onboarding_step_error( $step_id, $location ),
);
}
/**
* Get the recommended payment methods details for onboarding.
*
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return array The recommended payment methods details.
*/
public function get_onboarding_recommended_payment_methods( string $location ): array {
return $this->provider->get_recommended_payment_methods( $this->get_payment_gateway(), $location );
}
/**
* Initialize the test account for onboarding.
*
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param string $source Optional. The source for the current onboarding flow.
* If not provided, it will identify the source as the WC Admin Payments settings.
*
* @return array The result of the test account initialization.
* @throws ApiArgumentException|ApiException If the given onboarding step ID or step data is invalid.
* If the onboarding action can not be performed due to the current state
* of the site or there was an error initializing the test account.
*/
public function onboarding_test_account_init( string $location, string $source = '' ): array {
$this->check_if_onboarding_step_action_is_acceptable( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
// Nothing to do if we already have a connected test account.
if ( $this->has_test_account() ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_action_error',
esc_html__( 'A test account is already set up.', 'woocommerce' ),
(int) WP_Http::FORBIDDEN
);
}
// Nothing to do if there is a connected account, but it is not a test account.
if ( $this->has_account() ) {
// Mark the onboarding step as blocked, if it is not already.
$this->mark_onboarding_step_blocked(
self::ONBOARDING_STEP_TEST_ACCOUNT,
$location,
array(
'code' => 'account_already_exists',
'message' => esc_html__( 'An account is already set up. Reset the onboarding first.', 'woocommerce' ),
)
);
throw new ApiException(
'woocommerce_woopayments_onboarding_action_error',
esc_html__( 'An account is already set up. Reset the onboarding first.', 'woocommerce' ),
(int) WP_Http::FORBIDDEN
);
}
// Clear any previous failed status for the step.
$this->clear_onboarding_step_failed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
$selected_payment_methods = $this->get_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_PAYMENT_METHODS, $location, 'payment_methods', array() );
// Ensure the payment gateways logic is initialized in case actions need to be taken on payment gateway changes.
WC()->payment_gateways();
// Lock the onboarding to prevent concurrent actions.
$this->set_onboarding_lock();
if ( empty( $source ) ) {
// The default source is the WC Admin Payments settings.
$source = self::FROM_PAYMENT_SETTINGS;
}
try {
// Call the WooPayments API to initialize the test account.
$response = $this->proxy->call_static(
Utils::class,
'rest_endpoint_post_request',
'/wc/v3/payments/onboarding/test_drive_account/init',
array(
'country' => $location,
'capabilities' => $selected_payment_methods,
'source' => $source,
'from' => self::FROM_NOX_IN_CONTEXT,
)
);
} catch ( Exception $e ) {
// Catch any exceptions to allow for proper error handling and onboarding unlock.
$response = new WP_Error(
'woocommerce_woopayments_onboarding_client_api_exception',
esc_html__( 'An unexpected error happened while initializing the test account.', 'woocommerce' ),
array(
'code' => $e->getCode(),
'message' => $e->getMessage(),
'trace' => $e->getTrace(),
)
);
}
// Unlock the onboarding after the API call finished or errored.
$this->clear_onboarding_lock();
if ( is_wp_error( $response ) ) {
// Mark the onboarding step as failed.
$this->mark_onboarding_step_failed(
self::ONBOARDING_STEP_TEST_ACCOUNT,
$location,
array(
'code' => $response->get_error_code(),
'message' => $response->get_error_message(),
'context' => $response->get_error_data(),
)
);
throw new ApiException(
'woocommerce_woopayments_onboarding_client_api_error',
esc_html( $response->get_error_message() ),
(int) WP_Http::FAILED_DEPENDENCY,
map_deep( (array) $response->get_error_data(), 'esc_html' )
);
}
if ( ! is_array( $response ) || empty( $response['success'] ) ) {
// Mark the onboarding step as failed.
$this->mark_onboarding_step_failed(
self::ONBOARDING_STEP_TEST_ACCOUNT,
$location,
array(
'code' => 'malformed_response',
'message' => esc_html__( 'Received an unexpected response from the platform.', 'woocommerce' ),
'context' => array(
'response' => $response,
),
)
);
throw new ApiException(
'woocommerce_woopayments_onboarding_client_api_error',
esc_html__( 'Failed to initialize the test account.', 'woocommerce' ),
(int) WP_Http::FAILED_DEPENDENCY
);
}
// Record an event for the test account being initialized.
$this->record_event(
self::EVENT_PREFIX . 'onboarding_test_account_init',
$location,
array(
'source' => $source,
)
);
return $response;
}
/**
* Get the onboarding KYC account session.
*
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param array $self_assessment Optional. The self-assessment data.
* If not provided, the stored data will be used.
* @param string $source Optional. The source for the current onboarding flow.
* If not provided, it will identify the source as the WC Admin Payments settings.
*
* @return array The KYC account session data.
* @throws ApiException If the extension is not active, step requirements are not met, or
* the KYC session data could not be retrieved.
*/
public function get_onboarding_kyc_session( string $location, array $self_assessment = array(), string $source = '' ): array {
$this->check_if_onboarding_step_action_is_acceptable( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location );
if ( empty( $self_assessment ) ) {
// Get the stored self-assessment data.
$self_assessment = (array) $this->get_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location, 'self_assessment' );
}
// Clear any previous failed status for the step.
$this->clear_onboarding_step_failed( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location );
// Ensure the payment gateways logic is initialized in case actions need to be taken on payment gateway changes.
WC()->payment_gateways();
// Lock the onboarding to prevent concurrent actions.
$this->set_onboarding_lock();
if ( empty( $source ) ) {
// The default source is the WC Admin Payments settings.
$source = self::FROM_PAYMENT_SETTINGS;
}
try {
// Call the WooPayments API to get the KYC session.
$response = $this->proxy->call_static(
Utils::class,
'rest_endpoint_post_request',
'/wc/v3/payments/onboarding/kyc/session',
array(
'self_assessment' => $self_assessment,
)
);
} catch ( Exception $e ) {
// Catch any exceptions to allow for proper error handling and onboarding unlock.
$response = new WP_Error(
'woocommerce_woopayments_onboarding_client_api_exception',
esc_html__( 'An unexpected error happened while creating the KYC session.', 'woocommerce' ),
array(
'code' => $e->getCode(),
'message' => $e->getMessage(),
'trace' => $e->getTrace(),
)
);
}
// Unlock the onboarding after the API call finished or errored.
$this->clear_onboarding_lock();
if ( is_wp_error( $response ) ) {
// Mark the onboarding step as failed.
$this->mark_onboarding_step_failed(
self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
$location,
array(
'code' => $response->get_error_code(),
'message' => $response->get_error_message(),
'context' => $response->get_error_data(),
)
);
throw new ApiException(
'woocommerce_woopayments_onboarding_client_api_error',
esc_html( $response->get_error_message() ),
(int) WP_Http::FAILED_DEPENDENCY,
map_deep( (array) $response->get_error_data(), 'esc_html' )
);
}
if ( ! is_array( $response ) ) {
// Mark the onboarding step as failed.
$this->mark_onboarding_step_failed(
self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
$location,
array(
'code' => 'malformed_response',
'message' => esc_html__( 'Received an unexpected response from the platform.', 'woocommerce' ),
'context' => array(
'response' => $response,
),
)
);
throw new ApiException(
'woocommerce_woopayments_onboarding_client_api_error',
esc_html__( 'Failed to get the KYC session data.', 'woocommerce' ),
(int) WP_Http::FAILED_DEPENDENCY
);
}
// Add the user locale to the account session data to allow for localized KYC sessions.
$response['locale'] = $this->proxy->call_function( 'get_user_locale' );
// For sanity, make sure the test account step is blocked if not already completed,
// since we are doing live account KYC.
if ( ! $this->is_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location ) ) {
$this->mark_onboarding_step_blocked(
self::ONBOARDING_STEP_TEST_ACCOUNT,
$location,
array(
'code' => 'live_account_kyc_session',
'message' => esc_html__( 'A live account is set up. Reset the onboarding first.', 'woocommerce' ),
)
);
}
// Record an event for the KYC session being created.
$this->record_event(
self::EVENT_PREFIX . 'onboarding_kyc_session_created',
$location,
array(
'source' => $source,
)
);
return $response;
}
/**
* Finish the onboarding KYC account session.
*
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param string $source Optional. The source for the current onboarding flow.
* If not provided, it will identify the source as the WC Admin Payments settings.
*
* @return array The response from the WooPayments API.
* @throws ApiException If the extension is not active, step requirements are not met, or
* the KYC session could not be finished.
*/
public function finish_onboarding_kyc_session( string $location, string $source = '' ): array {
$this->check_if_onboarding_step_action_is_acceptable( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location );
// Ensure the payment gateways logic is initialized in case actions need to be taken on payment gateway changes.
WC()->payment_gateways();
// Lock the onboarding to prevent concurrent actions.
$this->set_onboarding_lock();
if ( empty( $source ) ) {
// The default source is the WC Admin Payments settings.
$source = self::FROM_PAYMENT_SETTINGS;
}
try {
// Call the WooPayments API to finalize the KYC session.
$response = $this->proxy->call_static(
Utils::class,
'rest_endpoint_post_request',
'/wc/v3/payments/onboarding/kyc/finalize',
array(
'source' => $source,
'from' => self::FROM_NOX_IN_CONTEXT,
)
);
} catch ( Exception $e ) {
// Catch any exceptions to allow for proper error handling and onboarding unlock.
$response = new WP_Error(
'woocommerce_woopayments_onboarding_client_api_exception',
esc_html__( 'An unexpected error happened while finalizing the KYC session.', 'woocommerce' ),
array(
'code' => $e->getCode(),
'message' => $e->getMessage(),
'trace' => $e->getTrace(),
)
);
}
// Unlock the onboarding after the API call finished or errored.
$this->clear_onboarding_lock();
if ( is_wp_error( $response ) ) {
// Mark the onboarding step as failed.
$this->mark_onboarding_step_failed(
self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
$location,
array(
'code' => $response->get_error_code(),
'message' => $response->get_error_message(),
'context' => $response->get_error_data(),
)
);
throw new ApiException(
'woocommerce_woopayments_onboarding_client_api_error',
esc_html( $response->get_error_message() ),
(int) WP_Http::FAILED_DEPENDENCY,
map_deep( (array) $response->get_error_data(), 'esc_html' )
);
}
if ( ! is_array( $response ) ) {
// Mark the onboarding step as failed.
$this->mark_onboarding_step_failed(
self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
$location,
array(
'code' => 'malformed_response',
'message' => esc_html__( 'Received an unexpected response from the platform.', 'woocommerce' ),
'context' => array(
'response' => $response,
),
)
);
throw new ApiException(
'woocommerce_woopayments_onboarding_client_api_error',
esc_html__( 'Failed to finish the KYC session.', 'woocommerce' ),
(int) WP_Http::FAILED_DEPENDENCY
);
}
// Record an event for the KYC session being finished.
$this->record_event(
self::EVENT_PREFIX . 'onboarding_kyc_session_finished',
$location,
array(
'source' => $source,
)
);
// Mark the business verification step as completed.
$this->mark_onboarding_step_completed( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location );
// For sanity, make sure the test account step is blocked if not already completed,
// since we are doing live account KYC.
if ( ! $this->is_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location ) ) {
$this->mark_onboarding_step_blocked(
self::ONBOARDING_STEP_TEST_ACCOUNT,
$location,
array(
'code' => 'live_account_kyc_session',
'message' => esc_html__( 'A live account is set up. Reset the onboarding first.', 'woocommerce' ),
)
);
}
return $response;
}
/**
* Preload the onboarding process.
*
* This method is used to run the heavier logic required for onboarding ahead of time,
* so that we can be quicker to respond to the user when they start the onboarding process.
*
* @return array An array containing the success status and any errors encountered during the preload.
* 'success' => true if the preload was successful, false otherwise.
* 'errors' => An array of error messages if any errors occurred, empty if no errors.
* @throws ApiException If the onboarding preload failed or the onboarding is locked.
*/
public function onboarding_preload(): array {
// If the onboarding is locked, we shouldn't do anything.
if ( $this->is_onboarding_locked() ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_locked',
esc_html__( 'Another onboarding action is already in progress. Please wait for it to finish.', 'woocommerce' ),
(int) WP_Http::CONFLICT
);
}
$result = true;
// Register the site to WPCOM if it is not already registered.
// This sets up the site for connection. For new sites, this tends to take a while.
// It is a prerequisite to generating the WPCOM/Jetpack authorization URL.
if ( ! $this->wpcom_connection_manager->is_connected() ) {
$result = $this->wpcom_connection_manager->try_registration();
if ( is_wp_error( $result ) ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_action_error',
esc_html( $result->get_error_message() ),
(int) WP_Http::INTERNAL_SERVER_ERROR,
map_deep( (array) $result->get_error_data(), 'esc_html' )
);
}
}
return array(
'success' => $result,
);
}
/**
* Reset onboarding.
*
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param string $from Optional. Where in the UI the request is coming from.
* If not provided, it will identify the origin as the WC Admin Payments settings.
* @param string $source Optional. The source for the current onboarding flow.
* If not provided, it will identify the source as the WC Admin Payments settings.
*
* @return array The response from the WooPayments API.
* @throws ApiException If we could not reset onboarding or there was an error.
*/
public function reset_onboarding( string $location, string $from = '', string $source = '' ): array {
$this->check_if_onboarding_action_is_acceptable();
// Ensure the payment gateways logic is initialized in case actions need to be taken on payment gateway changes.
WC()->payment_gateways();
// Lock the onboarding to prevent concurrent actions.
$this->set_onboarding_lock();
// If no source is provided, default to the WC Admin Payments settings.
if ( empty( $source ) ) {
$source = self::FROM_PAYMENT_SETTINGS;
}
try {
// Call the WooPayments API to reset onboarding.
$response = $this->proxy->call_static(
Utils::class,
'rest_endpoint_post_request',
'/wc/v3/payments/onboarding/reset',
array(
'from' => ! empty( $from ) ? esc_attr( $from ) : self::FROM_PAYMENT_SETTINGS,
'source' => $source,
)
);
} catch ( Exception $e ) {
// Catch any exceptions to allow for proper error handling and onboarding unlock.
$response = new WP_Error(
'woocommerce_woopayments_onboarding_client_api_exception',
esc_html__( 'An unexpected error happened while resetting onboarding.', 'woocommerce' ),
array(
'code' => $e->getCode(),
'message' => $e->getMessage(),
'trace' => $e->getTrace(),
)
);
}
// Unlock the onboarding after the API call finished or errored.
$this->clear_onboarding_lock();
if ( is_wp_error( $response ) ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_client_api_error',
esc_html( $response->get_error_message() ),
(int) WP_Http::FAILED_DEPENDENCY,
map_deep( (array) $response->get_error_data(), 'esc_html' )
);
}
if ( ! is_array( $response ) || empty( $response['success'] ) ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_client_api_error',
esc_html__( 'Failed to reset onboarding.', 'woocommerce' ),
(int) WP_Http::FAILED_DEPENDENCY
);
}
// Clean up any NOX-specific onboarding data.
$this->proxy->call_function( 'delete_option', self::NOX_PROFILE_OPTION_KEY );
// Record an event for the onboarding reset.
$this->record_event(
self::EVENT_PREFIX . 'onboarding_reset',
$location,
array(
'source' => $source,
)
);
return $response;
}
/**
* Disable test account during the switch-to-live onboarding flow.
*
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param string $from Optional. Where in the UI the request is coming from.
* If not provided, it will identify the origin as the WC Admin Payments settings.
* @param string $source Optional. The source for the current onboarding flow.
* If not provided, it will identify the source as the WC Admin Payments settings.
*
* @return array The response from the WooPayments API.
* @throws ApiException If we could not disable the test account or there was an error.
*/
public function disable_test_account( string $location, string $from = '', string $source = '' ): array {
$this->check_if_onboarding_action_is_acceptable();
// Ensure the payment gateways logic is initialized in case actions need to be taken on payment gateway changes.
WC()->payment_gateways();
// Lock the onboarding to prevent concurrent actions.
$this->set_onboarding_lock();
// If no source is provided, default to the WC Admin Payments settings.
if ( empty( $source ) ) {
$source = self::FROM_PAYMENT_SETTINGS;
}
try {
// Call the WooPayments API to disable the test account and prepare for the switch to live.
$response = $this->proxy->call_static(
Utils::class,
'rest_endpoint_post_request',
'/wc/v3/payments/onboarding/test_drive_account/disable',
array(
'from' => ! empty( $from ) ? esc_attr( $from ) : self::FROM_PAYMENT_SETTINGS,
'source' => $source,
)
);
} catch ( Exception $e ) {
// Catch any exceptions to allow for proper error handling and onboarding unlock.
$response = new WP_Error(
'woocommerce_woopayments_onboarding_client_api_exception',
esc_html__( 'An unexpected error happened while disabling the test account.', 'woocommerce' ),
array(
'code' => $e->getCode(),
'message' => $e->getMessage(),
'trace' => $e->getTrace(),
)
);
}
// Unlock the onboarding after the API call finished or errored.
$this->clear_onboarding_lock();
if ( is_wp_error( $response ) ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_client_api_error',
esc_html( $response->get_error_message() ),
(int) WP_Http::FAILED_DEPENDENCY,
map_deep( (array) $response->get_error_data(), 'esc_html' )
);
}
if ( ! is_array( $response ) || empty( $response['success'] ) ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_client_api_error',
esc_html__( 'Failed to disable the test account.', 'woocommerce' ),
(int) WP_Http::FAILED_DEPENDENCY
);
}
// For sanity, make sure the payment methods step is marked as completed.
// This is to avoid the user being prompted to set up payment methods again.
$this->mark_onboarding_step_completed( self::ONBOARDING_STEP_PAYMENT_METHODS, $location );
// For sanity, make sure the test account step is marked as completed and not blocked or failed.
// After disabling a test account, the user should be prompted to set up a live account.
$this->mark_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
$this->clear_onboarding_step_blocked( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
$this->clear_onboarding_step_failed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location );
// Record an event for the test account being disabled.
$this->record_event(
self::EVENT_PREFIX . 'onboarding_test_account_disabled',
$location,
array(
'source' => $source,
)
);
return $response;
}
/**
* Check if an onboarding action should be allowed to be processed.
*
* @return void
* @throws ApiException If the extension is not active or onboarding is locked.
*/
private function check_if_onboarding_action_is_acceptable() {
// If the WooPayments plugin is not active, we can't do anything.
if ( ! $this->is_extension_active() ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_extension_not_active',
/* translators: %s: WooPayments. */
sprintf( esc_html__( 'The %s extension is not active.', 'woocommerce' ), 'WooPayments' ),
(int) WP_Http::FORBIDDEN
);
}
// If the WooPayments installed version is less than the minimum required version, we can't do anything.
if ( Constants::is_defined( 'WCPAY_VERSION_NUMBER' ) &&
version_compare( Constants::get_constant( 'WCPAY_VERSION_NUMBER' ), self::EXTENSION_MINIMUM_VERSION, '<' ) ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_extension_version',
/* translators: %s: WooPayments. */
sprintf( esc_html__( 'The %s extension is not up-to-date. Please update to the latest version and try again.', 'woocommerce' ), 'WooPayments' ),
(int) WP_Http::FORBIDDEN
);
}
// If the onboarding is locked, we shouldn't do anything.
if ( $this->is_onboarding_locked() ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_locked',
esc_html__( 'Another onboarding action is already in progress. Please wait for it to finish.', 'woocommerce' ),
(int) WP_Http::CONFLICT
);
}
}
/**
* Check if an onboarding step action should be allowed to be processed.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return void
* @throws ApiArgumentException If the onboarding step ID is invalid.
* @throws ApiException If the extension is not active or step requirements are not met.
*/
private function check_if_onboarding_step_action_is_acceptable( string $step_id, string $location ): void {
// First, check general onboarding actions.
$this->check_if_onboarding_action_is_acceptable();
// Second, do onboarding step specific checks.
if ( ! $this->is_valid_onboarding_step_id( $step_id ) ) {
throw new ApiArgumentException(
'woocommerce_woopayments_onboarding_invalid_step_id',
esc_html__( 'Invalid onboarding step ID.', 'woocommerce' ),
(int) WP_Http::BAD_REQUEST
);
}
if ( ! $this->check_onboarding_step_requirements( $step_id, $location ) ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_step_requirements_not_met',
esc_html__( 'Onboarding step requirements are not met.', 'woocommerce' ),
(int) WP_Http::FORBIDDEN
);
}
if ( $this->is_onboarding_step_blocked( $step_id, $location ) ) {
throw new ApiException(
'woocommerce_woopayments_onboarding_step_blocked',
esc_html__( 'There are environment or store setup issues which are blocking progress. Please resolve them to proceed.', 'woocommerce' ),
(int) WP_Http::FORBIDDEN,
array(
'error' => map_deep( $this->get_onboarding_step_error( $step_id, $location ), 'esc_html' ),
),
);
}
}
/**
* Check if the onboarding is locked.
*
* @return bool Whether the onboarding is locked.
*/
private function is_onboarding_locked(): bool {
return 'yes' === $this->proxy->call_function( 'get_option', self::NOX_ONBOARDING_LOCKED_KEY, 'no' );
}
/**
* Lock the onboarding.
*
* This will save a flag in the database to indicate that onboarding is locked.
* This is used to prevent certain onboarding actions to happen while others have not finished.
* This is especially important for actions that modify the account (initializing it, deleting it, etc.)
* These actions tend to be longer-running and we want to have backstops in place to prevent race conditions.
*
* @return void
*/
private function set_onboarding_lock(): void {
$this->proxy->call_function( 'update_option', self::NOX_ONBOARDING_LOCKED_KEY, 'yes' );
}
/**
* Unlock the onboarding.
*
* @return void
*/
private function clear_onboarding_lock(): void {
// We update rather than delete the option for performance reasons.
$this->proxy->call_function( 'update_option', self::NOX_ONBOARDING_LOCKED_KEY, 'no' );
}
/**
* Get the onboarding details for each step.
*
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param string $rest_path The REST API path to use for constructing REST API URLs.
*
* @return array[] The list of onboarding steps details.
* @throws Exception If there was an error generating the onboarding steps details.
*/
private function get_onboarding_steps( string $location, string $rest_path ): array {
$steps = array();
// Add the payment methods onboarding step details.
$steps[] = $this->standardize_onboarding_step_details(
array(
'id' => self::ONBOARDING_STEP_PAYMENT_METHODS,
'context' => array(
'recommended_pms' => $this->get_onboarding_recommended_payment_methods( $location ),
'pms_state' => $this->get_onboarding_payment_methods_state( $location ),
),
'actions' => array(
'start' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_PAYMENT_METHODS . '/start' ),
),
'save' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_PAYMENT_METHODS . '/save' ),
),
'finish' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_PAYMENT_METHODS . '/finish' ),
),
),
),
$location,
$rest_path
);
// Add the WPCOM connection onboarding step details.
$wpcom_step = $this->standardize_onboarding_step_details(
array(
'id' => self::ONBOARDING_STEP_WPCOM_CONNECTION,
'context' => array(
'connection_state' => $this->get_wpcom_connection_state(),
),
),
$location,
$rest_path
);
// If the WPCOM connection is already set up, we don't need to add anything more.
if ( self::ONBOARDING_STEP_STATUS_COMPLETED !== $wpcom_step['status'] ) {
// Craft the return URL.
// By default, we return the user to the onboarding modal.
$return_url = $this->proxy->call_static(
Utils::class,
'wc_payments_settings_url',
self::ONBOARDING_PATH_BASE,
array(
'wpcom_connection_return' => '1', // URL query flag so we can properly identify when the user returns.
)
);
// Try to generate the authorization URL.
$wpcom_connection = $this->get_wpcom_connection_authorization( $return_url );
if ( ! $wpcom_connection['success'] ) {
$wpcom_step['errors'] = array_values( $wpcom_connection['errors'] );
}
$wpcom_step['actions'] = array(
'start' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_WPCOM_CONNECTION . '/start' ),
),
'auth' => array(
'type' => self::ACTION_TYPE_REDIRECT,
'href' => $wpcom_connection['url'],
),
);
}
$steps[] = $wpcom_step;
// Test account onboarding step is unavailable in UAE and Singapore.
if ( ! in_array( $location, array( 'AE', 'SG' ), true ) ) {
$test_account_step = $this->standardize_onboarding_step_details(
array(
'id' => self::ONBOARDING_STEP_TEST_ACCOUNT,
),
$location,
$rest_path
);
// If the step is not completed, we need to add the actions.
if ( self::ONBOARDING_STEP_STATUS_COMPLETED !== $test_account_step['status'] ) {
$test_account_step['actions'] = array(
'start' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_TEST_ACCOUNT . '/start' ),
),
'init' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_TEST_ACCOUNT . '/init' ),
),
'finish' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_TEST_ACCOUNT . '/finish' ),
),
);
}
$steps[] = $test_account_step;
}
// Add the live account business verification onboarding step details.
$business_verification_step = $this->standardize_onboarding_step_details(
array(
'id' => self::ONBOARDING_STEP_BUSINESS_VERIFICATION,
'context' => array(
'fields' => array(),
'sub_steps' => $this->get_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location, 'sub_steps', array() ),
'self_assessment' => $this->get_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location, 'self_assessment', array() ),
'has_test_account' => $this->has_test_account(),
),
),
$location,
$rest_path
);
// Try to get the pre-KYC fields, but only if the required step is completed.
// This is because WooPayments needs a working WPCOM connection to be able to fetch the fields.
if ( $this->check_onboarding_step_requirements( self::ONBOARDING_STEP_BUSINESS_VERIFICATION, $location ) ) {
try {
$business_verification_step['context']['fields'] = $this->get_onboarding_kyc_fields( $location );
} catch ( Exception $e ) {
$business_verification_step['errors'][] = array(
'code' => 'fields_error',
'message' => $e->getMessage(),
);
}
}
// If the step is not completed, we need to add the actions.
if ( self::ONBOARDING_STEP_STATUS_COMPLETED !== $business_verification_step['status'] ) {
$business_verification_step['actions'] = array(
'start' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/start' ),
),
'save' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/save' ),
),
'kyc_session' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/kyc_session' ),
),
'kyc_session_finish' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/kyc_session/finish' ),
),
'kyc_fallback' => array(
'type' => self::ACTION_TYPE_REDIRECT,
'href' => $this->get_onboarding_kyc_fallback_url(),
),
'finish' => array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_BUSINESS_VERIFICATION . '/finish' ),
),
);
}
$steps[] = $business_verification_step;
// Do a complete list standardization, for safety.
return $this->standardize_onboarding_steps_details( $steps, $location, $rest_path );
}
/**
* Standardize (and sanity check) the onboarding step details.
*
* @param array $step_details The onboarding step details to standardize.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param string $rest_path The REST API path to use for constructing REST API URLs.
*
* @return array The standardized onboarding step details.
* @throws Exception If the onboarding step details are missing required entries or if the step ID is invalid.
*/
private function standardize_onboarding_step_details( array $step_details, string $location, string $rest_path ): array {
// If the required keys are not present, throw.
if ( ! isset( $step_details['id'] ) ) {
/* translators: %s: The required key that is missing. */
throw new Exception( sprintf( esc_html__( 'The onboarding step is missing required entries: %s', 'woocommerce' ), 'id' ) );
}
// Validate the step ID.
if ( ! $this->is_valid_onboarding_step_id( $step_details['id'] ) ) {
/* translators: %s: The invalid step ID. */
throw new Exception( sprintf( esc_html__( 'The onboarding step ID is invalid: %s', 'woocommerce' ), esc_attr( $step_details['id'] ) ) );
}
if ( empty( $step_details['status'] ) ) {
$step_details['status'] = $this->get_onboarding_step_status( $step_details['id'], $location );
}
if ( empty( $step_details['errors'] ) ) {
$step_details['errors'] = array();
// For blocked or failed steps, we include any stored error.
if ( in_array( $step_details['status'], array( self::ONBOARDING_STEP_STATUS_BLOCKED, self::ONBOARDING_STEP_STATUS_FAILED ), true ) ) {
$stored_error = $this->get_onboarding_step_error( $step_details['id'], $location );
if ( ! empty( $stored_error ) ) {
$step_details['errors'] = array( $stored_error );
}
}
}
// Ensure that any step has the general actions.
if ( empty( $step_details['actions'] ) ) {
$step_details['actions'] = array();
}
// Any step can be checked for its status.
if ( empty( $step_details['actions']['check'] ) ) {
$step_details['actions']['check'] = array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . $step_details['id'] . '/check' ),
);
}
// Any step can be cleaned of its progress.
if ( empty( $step_details['actions']['clean'] ) ) {
$step_details['actions']['clean'] = array(
'type' => self::ACTION_TYPE_REST,
'href' => rest_url( trailingslashit( $rest_path ) . $step_details['id'] . '/clean' ),
);
}
return array(
'id' => $step_details['id'],
'path' => $step_details['path'] ?? trailingslashit( self::ONBOARDING_PATH_BASE ) . $step_details['id'],
'required_steps' => $step_details['required_steps'] ?? $this->get_onboarding_step_required_steps( $step_details['id'] ),
'status' => $step_details['status'],
'errors' => $step_details['errors'],
'actions' => $step_details['actions'],
'context' => $step_details['context'] ?? array(),
);
}
/**
* Standardize (and sanity check) the onboarding steps list.
*
* @param array $steps The onboarding steps list to standardize.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param string $rest_path The REST API path to use for constructing REST API URLs.
*
* @return array The standardized onboarding steps list.
* @throws Exception If some onboarding steps are missing required entries or if invalid step IDs are present.
*/
private function standardize_onboarding_steps_details( array $steps, string $location, string $rest_path ): array {
$standardized_steps = array();
foreach ( $steps as $step ) {
$standardized_steps[] = $this->standardize_onboarding_step_details( $step, $location, $rest_path );
}
return $standardized_steps;
}
/**
* Get the entire stored NOX profile data
*
* @return array The stored NOX profile.
*/
private function get_nox_profile(): array {
$nox_profile = $this->proxy->call_function( 'get_option', self::NOX_PROFILE_OPTION_KEY, array() );
if ( empty( $nox_profile ) ) {
$nox_profile = array();
} else {
$nox_profile = maybe_unserialize( $nox_profile );
}
return $nox_profile;
}
/**
* Get the onboarding step data from the NOX profile.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return array The onboarding step stored data from the NOX profile.
* If the step data is not found, an empty array is returned.
*/
private function get_nox_profile_onboarding_step( string $step_id, string $location ): array {
$nox_profile = $this->get_nox_profile();
if ( empty( $nox_profile['onboarding'] ) ) {
$nox_profile['onboarding'] = array();
}
if ( empty( $nox_profile['onboarding'][ $location ] ) ) {
$nox_profile['onboarding'][ $location ] = array();
}
if ( empty( $nox_profile['onboarding'][ $location ]['steps'] ) ) {
$nox_profile['onboarding'][ $location ]['steps'] = array();
}
if ( empty( $nox_profile['onboarding'][ $location ]['steps'][ $step_id ] ) ) {
$nox_profile['onboarding'][ $location ]['steps'][ $step_id ] = array();
}
return $nox_profile['onboarding'][ $location ]['steps'][ $step_id ];
}
/**
* Save the onboarding step data in the NOX profile.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param array $data The onboarding step data to save in the profile.
*
* @return bool Whether the onboarding step data was saved.
*/
private function save_nox_profile_onboarding_step( string $step_id, string $location, array $data ): bool {
$nox_profile = $this->get_nox_profile();
if ( empty( $nox_profile['onboarding'] ) ) {
$nox_profile['onboarding'] = array();
}
if ( empty( $nox_profile['onboarding'][ $location ] ) ) {
$nox_profile['onboarding'][ $location ] = array();
}
if ( empty( $nox_profile['onboarding'][ $location ]['steps'] ) ) {
$nox_profile['onboarding'][ $location ]['steps'] = array();
}
// Update the stored step data.
$nox_profile['onboarding'][ $location ]['steps'][ $step_id ] = $data;
return $this->proxy->call_function( 'update_option', self::NOX_PROFILE_OPTION_KEY, $nox_profile, false );
}
/**
* Get an entry from the NOX profile onboarding step details.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param string $entry The entry to get from the step data.
* @param mixed $default_value The default value to return if the entry is not found.
*
* @return mixed The entry from the NOX profile step details. If the entry is not found, the default value is returned.
*/
private function get_nox_profile_onboarding_step_entry( string $step_id, string $location, string $entry, $default_value = array() ): array {
$step_details = $this->get_nox_profile_onboarding_step( $step_id, $location );
if ( ! isset( $step_details[ $entry ] ) ) {
return $default_value;
}
return $step_details[ $entry ];
}
/**
* Save an entry in the NOX profile onboarding step details.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param string $entry The entry key under which to save in the step data.
* @param array $data The data to save in the step data.
*
* @return bool Whether the onboarding step data was saved.
*/
private function save_nox_profile_onboarding_step_entry( string $step_id, string $location, string $entry, array $data ): bool {
$step_details = $this->get_nox_profile_onboarding_step( $step_id, $location );
// Update the stored step data.
$step_details[ $entry ] = $data;
return $this->save_nox_profile_onboarding_step( $step_id, $location, $step_details );
}
/**
* Get a data entry from the NOX profile onboarding step details.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param string $entry The entry to get from the step `data`.
* @param mixed $default_value The default value to return if the entry is not found.
*
* @return mixed The entry value from the NOX profile stored step data.
* If the entry is not found, the default value is returned.
*/
private function get_nox_profile_onboarding_step_data_entry( string $step_id, string $location, string $entry, $default_value = false ) {
$step_details_data = $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'data' );
if ( ! isset( $step_details_data[ $entry ] ) ) {
return $default_value;
}
return $step_details_data[ $entry ];
}
/**
* Save a data entry in the NOX profile onboarding step details.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
* @param string $entry The entry key under which to save in the step `data`.
* @param mixed $data The value to save.
*
* @return bool Whether the onboarding step data was saved.
*/
private function save_nox_profile_onboarding_step_data_entry( string $step_id, string $location, string $entry, $data ): bool {
$step_details_data = $this->get_nox_profile_onboarding_step_entry( $step_id, $location, 'data' );
// Update the stored step data.
$step_details_data[ $entry ] = $data;
return $this->save_nox_profile_onboarding_step_entry( $step_id, $location, 'data', $step_details_data );
}
/**
* Get the IDs of the onboarding steps that are required for the given step.
*
* @param string $step_id The ID of the onboarding step.
*
* @return array|string[] The IDs of the onboarding steps that are required for the given step.
*/
private function get_onboarding_step_required_steps( string $step_id ): array {
switch ( $step_id ) {
// Both the test account and business verification (live account) steps require a working WPCOM connection.
case self::ONBOARDING_STEP_TEST_ACCOUNT:
case self::ONBOARDING_STEP_BUSINESS_VERIFICATION:
return array(
self::ONBOARDING_STEP_WPCOM_CONNECTION,
);
default:
return array();
}
}
/**
* Check if the requirements for an onboarding step are met.
*
* @param string $step_id The ID of the onboarding step.
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return bool Whether the onboarding step requirements are met.
* @throws ApiArgumentException If the given onboarding step ID is invalid.
*/
private function check_onboarding_step_requirements( string $step_id, string $location ): bool {
$requirements = $this->get_onboarding_step_required_steps( $step_id );
foreach ( $requirements as $required_step_id ) {
if ( $this->get_onboarding_step_status( $required_step_id, $location ) !== self::ONBOARDING_STEP_STATUS_COMPLETED ) {
return false;
}
}
return true;
}
/**
* Get the payment methods state for onboarding.
*
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return array The onboarding payment methods state.
*/
private function get_onboarding_payment_methods_state( string $location ): array {
// First, get the recommended payment methods details from the provider.
// We will use their enablement state as the default.
// Note: The list is validated and standardized by the provider, so we don't need to do it here.
$recommended_pms = $this->get_onboarding_recommended_payment_methods( $location );
// Grab the stored payment methods state
// (a key-value array of payment method IDs and if they should be automatically enabled or not).
$step_pms_data = (array) $this->get_nox_profile_onboarding_step_data_entry( self::ONBOARDING_STEP_PAYMENT_METHODS, $location, 'payment_methods' );
$payment_methods_state = array();
$apple_pay_enabled = false;
$google_pay_enabled = false;
foreach ( $recommended_pms as $recommended_pm ) {
$pm_id = $recommended_pm['id'];
/**
* We need to handle Apple Pay and Google Pay separately.
* They are not stored in the same way as the other payment methods.
*/
if ( 'apple_pay' === $pm_id ) {
$apple_pay_enabled = $recommended_pm['enabled'];
continue;
}
if ( 'google_pay' === $pm_id ) {
$google_pay_enabled = $recommended_pm['enabled'];
continue;
}
// Start with the recommended enabled state.
$payment_methods_state[ $pm_id ] = $recommended_pm['enabled'];
// Force enable if required.
if ( $recommended_pm['required'] ) {
$payment_methods_state[ $pm_id ] = true;
continue;
}
// Check the stored state, if any.
if ( isset( $step_pms_data[ $pm_id ] ) ) {
$payment_methods_state[ $pm_id ] = filter_var( $step_pms_data[ $pm_id ], FILTER_VALIDATE_BOOLEAN );
}
}
// Combine Apple Pay and Google Pay into a single `apple_google` entry.
$apple_google_enabled = $apple_pay_enabled || $google_pay_enabled;
// Optionally also respect stored state or forced requirements if needed here.
$payment_methods_state['apple_google'] = $apple_google_enabled;
return $payment_methods_state;
}
/**
* Get the WPCOM (Jetpack) connection authorization details.
*
* @param string $return_url The URL to redirect to after the connection is set up.
*
* @return array The WPCOM connection authorization details.
*/
private function get_wpcom_connection_authorization( string $return_url ): array {
return $this->proxy->call_static( Utils::class, 'get_wpcom_connection_authorization', $return_url );
}
/**
* Get the store's WPCOM (Jetpack) connection state.
*
* @return array The WPCOM connection state.
*/
private function get_wpcom_connection_state(): array {
$is_connected = $this->wpcom_connection_manager->is_connected();
$has_connected_owner = $this->wpcom_connection_manager->has_connected_owner();
return array(
'has_working_connection' => $this->has_working_wpcom_connection(),
'is_store_connected' => $is_connected,
'has_connected_owner' => $has_connected_owner,
'is_connection_owner' => $has_connected_owner && $this->wpcom_connection_manager->is_connection_owner(),
);
}
/**
* Check if the store has a working WPCOM connection.
*
* The store is considered to have a working WPCOM connection if:
* - The store is connected to WPCOM (blog ID and tokens are set).
* - The store connection has a connected owner (connection owner is set).
*
* @return bool Whether the store has a working WPCOM connection.
*/
private function has_working_wpcom_connection(): bool {
return $this->wpcom_connection_manager->is_connected() && $this->wpcom_connection_manager->has_connected_owner();
}
/**
* Check if the WooPayments plugin is active.
*
* @return boolean
*/
private function is_extension_active(): bool {
return $this->proxy->call_function( 'class_exists', '\WC_Payments' );
}
/**
* Get the main payment gateway instance.
*
* @return \WC_Payment_Gateway The main payment gateway instance.
*/
private function get_payment_gateway(): \WC_Payment_Gateway {
return $this->proxy->call_static( '\WC_Payments', 'get_gateway' );
}
/**
* Determine if WooPayments has an account set up.
*
* @return bool Whether WooPayments has an account set up.
*/
private function has_account(): bool {
return $this->provider->is_account_connected( $this->get_payment_gateway() );
}
/**
* Determine if WooPayments has a valid, fully onboarded account set up.
*
* @return bool Whether WooPayments has a valid, fully onboarded account set up.
*/
private function has_valid_account(): bool {
if ( ! $this->has_account() ) {
return false;
}
$account_service = $this->proxy->call_static( '\WC_Payments', 'get_account_service' );
return $account_service->is_stripe_account_valid();
}
/**
* Determine if WooPayments has a working account set up.
*
* This is a more specific check than has_valid_account() and checks if payments are enabled for the account.
*
* @return bool Whether WooPayments has a working account set up.
*/
private function has_working_account(): bool {
if ( ! $this->has_account() ) {
return false;
}
$account_service = $this->proxy->call_static( '\WC_Payments', 'get_account_service' );
$account_status = $account_service->get_account_status_data();
return ! empty( $account_status['paymentsEnabled'] );
}
/**
* Determine if WooPayments has a test account set up.
*
* @return bool Whether WooPayments has a test account set up.
*/
private function has_test_account(): bool {
if ( ! $this->has_account() ) {
return false;
}
$account_service = $this->proxy->call_static( '\WC_Payments', 'get_account_service' );
$account_status = $account_service->get_account_status_data();
return ! empty( $account_status['testDrive'] );
}
/**
* Determine if WooPayments has a live account set up.
*
* @return bool Whether WooPayments has a test account set up.
*/
private function has_live_account(): bool {
if ( ! $this->has_account() ) {
return false;
}
$account_service = $this->proxy->call_static( '\WC_Payments', 'get_account_service' );
$account_status = $account_service->get_account_status_data();
return ! empty( $account_status['isLive'] );
}
/**
* Get the onboarding fields data for the KYC business verification.
*
* @param string $location The location for which we are onboarding.
* This is a ISO 3166-1 alpha-2 country code.
*
* @return array The onboarding fields data.
* @throws Exception If the onboarding fields data could not be retrieved or there was an error.
*/
private function get_onboarding_kyc_fields( string $location ): array {
// Call the WooPayments API to get the onboarding fields.
$response = $this->proxy->call_static( Utils::class, 'rest_endpoint_get_request', '/wc/v3/payments/onboarding/fields' );
if ( is_wp_error( $response ) ) {
throw new Exception( esc_html( $response->get_error_message() ) );
}
if ( ! is_array( $response ) || ! isset( $response['data'] ) ) {
throw new Exception( esc_html__( 'Failed to get onboarding fields data.', 'woocommerce' ) );
}
$fields = $response['data'];
// If there is no available_countries entry, add it.
if ( ! isset( $fields['available_countries'] ) && $this->proxy->call_function( 'is_callable', '\WC_Payments_Utils::supported_countries' ) ) {
$fields['available_countries'] = $this->proxy->call_static( '\WC_Payments_Utils', 'supported_countries' );
}
$fields['location'] = $location;
return $fields;
}
/**
* Get the fallback URL for the embedded KYC flow.
*
* @return string The fallback URL for the embedded KYC flow.
*/
private function get_onboarding_kyc_fallback_url(): string {
if ( $this->proxy->call_function( 'is_callable', '\WC_Payments_Account::get_connect_url' ) ) {
return $this->proxy->call_static( '\WC_Payments_Account', 'get_connect_url', self::FROM_NOX_IN_CONTEXT );
}
// Fall back to the provider onboarding URL.
return $this->provider->get_onboarding_url(
$this->get_payment_gateway(),
Utils::wc_payments_settings_url( self::ONBOARDING_PATH_BASE, array( 'from' => self::FROM_KYC ) )
);
}
/**
* Get the WooPayments Overview page URL.
*
* @return string The WooPayments Overview page URL.
*/
private function get_overview_page_url(): string {
if ( $this->proxy->call_function( 'is_callable', '\WC_Payments_Account::get_overview_page_url' ) ) {
return add_query_arg(
array(
'from' => self::FROM_NOX_IN_CONTEXT,
),
$this->proxy->call_static( '\WC_Payments_Account', 'get_overview_page_url' )
);
}
// Fall back to the known WooPayments Overview page URL.
return add_query_arg(
array(
'page' => 'wc-admin',
'path' => '/payments/overview',
'from' => self::FROM_NOX_IN_CONTEXT,
),
admin_url( 'admin.php' )
);
}
/**
* Get the business location country code for the Payments settings.
*
* @return string The ISO 3166-1 alpha-2 country code to use for the overall business location.
* If the user didn't set a location, the WC base location country code is used.
*/
private function get_country(): string {
$user_nox_meta = get_user_meta( get_current_user_id(), self::PAYMENTS_NOX_PROFILE_KEY, true );
if ( ! empty( $user_nox_meta['business_country_code'] ) ) {
return $user_nox_meta['business_country_code'];
}
return WC()->countries->get_base_country();
}
/**
* Send a Tracks event.
*
* By default, Woo adds `url`, `blog_lang`, `blog_id`, `store_id`, `products_count`, and `wc_version`
* properties to every event.
*
* @param string $name The event name.
* If it is not prefixed with self::EVENT_PREFIX, it will be prefixed with it.
* @param string $business_country The business registration country code as set in the WooCommerce Payments settings.
* This is a ISO 3166-1 alpha-2 country code.
* @param array $properties Optional. The event custom properties.
* These properties will be merged with the default properties.
* Default properties values take precedence over the provided ones.
*
* @return void
*/
private function record_event( string $name, string $business_country, array $properties = array() ) {
if ( ! function_exists( 'wc_admin_record_tracks_event' ) ) {
return;
}
// If the event name is empty, we don't record it.
if ( empty( $name ) ) {
return;
}
// If the event name is not prefixed with `settings_payments_`, we prefix it.
if ( ! str_starts_with( $name, self::EVENT_PREFIX ) ) {
$name = self::EVENT_PREFIX . $name;
}
// Add default properties to every event and overwrite custom properties with the same keys.
$properties = array_merge(
$properties,
array(
'business_country' => $business_country,
),
);
wc_admin_record_tracks_event( $name, $properties );
}
}