$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 ); } }