Commit e50bda3f865 for woocommerce
commit e50bda3f865175dad4a0b950be9f135d90ce29aa
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date: Mon Oct 26 20:40:48 2026 +0000
Register with WordPress.com on the WooPayments Connect click, not on reads (#69844)
* Register with WordPress.com on the WooPayments Connect click, not on reads
Building the WooPayments onboarding steps generated the WordPress.com
authorization URL, and generating it registers an unregistered store
first. Every caller that reads the WooPayments provider details (the
Payments task, the WP Dashboard setup widget, Settings > Payments, the
Payments menu badge) therefore attempted a blocking registration, and a
store that cannot register retried on every one of them.
Until the store is registered, the WPCOM connection step now offers an
`authorize` REST action instead of the URL. The Connect button calls it,
and it registers the store and returns the URL to redirect to. Both the
action and the onboarding preload pass the connection package's $force
flag, since both run on a merchant's click; older connection packages
ignore it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(payments): Don't force WordPress.com registration in the WooPayments preload
The preload runs in the background when the merchant starts setting up
WooPayments, before they ask to connect. Forcing it skipped the Jetpack
connection package's offline mode check and reset its backoff on every
setup click. The Connect click still forces registration.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: Type the WPCOM connection checks added for the Connect click
PHPStan failed on the new is_connected() calls, which go through a
property typed as the Jetpack manager or any object, and on the new
REST handler's untyped WP_REST_Request. A small helper narrows the
manager type instead of growing the baseline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore: Shorten the changelog entry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Respect the onboarding lock before connecting to WordPress.com
* Keep WordPress.com authorization sources consistent
* Preserve registration error details in the Connect response
* Show readable WordPress.com connection errors
* Keep Connect accessible while registration is pending
* Reject WordPress.com authorization when WooPayments can't onboard
The authorize action only checked the onboarding lock. The details read
that used to register the store also refused when WooPayments was
inactive or below the minimum version, so a stale modal could still
register the store. Use the shared onboarding guard, which returns the
same 409 for the lock.
* Cover the Connect redirect and authorization failure paths
Test the redirect to the returned authorization URL, the auth fallback
for registered stores, a registration that returns false, and a failed
authorization URL.
---------
Co-authored-by: bindlegirl <1242807+bindlegirl@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-woopayments-register-on-connect-click b/plugins/woocommerce/changelog/fix-woopayments-register-on-connect-click
new file mode 100644
index 00000000000..0ef902039f0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-woopayments-register-on-connect-click
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Register the store with WordPress.com when the merchant clicks Connect in WooPayments onboarding, not on admin page loads.
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/wpcom-connection/index.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/wpcom-connection/index.tsx
index 8ded3f320e9..04ded598102 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/wpcom-connection/index.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/wpcom-connection/index.tsx
@@ -6,6 +6,8 @@ import { __ } from '@wordpress/i18n';
import { Button, Notice } from '@wordpress/components';
import { useState } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';
+import { decodeEntities } from '@wordpress/html-entities';
+import { speak } from '@wordpress/a11y';
/**
* Internal dependencies
@@ -20,11 +22,15 @@ export const JetpackStep: React.FC = () => {
useOnboardingContext();
const [ isConnectButtonLoading, setIsConnectButtonLoading ] =
useState( false );
+ const [ authorizeError, setAuthorizeError ] = useState< string | null >(
+ null
+ );
return (
<>
<WooPaymentsStepHeader onClose={ closeModal } />
- { currentStep?.errors && currentStep.errors.length > 0 && (
+ { ( authorizeError ||
+ ( currentStep?.errors && currentStep.errors.length > 0 ) ) && (
<Notice
status="error"
isDismissible={ false }
@@ -35,7 +41,8 @@ export const JetpackStep: React.FC = () => {
{ ...{ role: 'alert' } }
>
<p>
- { currentStep.errors[ 0 ]?.message ||
+ { authorizeError ||
+ currentStep?.errors?.[ 0 ]?.message ||
__(
'Something went wrong. Please try again.',
'woocommerce'
@@ -59,6 +66,7 @@ export const JetpackStep: React.FC = () => {
className="settings-payments-onboarding-modal__step--content-jetpack-button"
isBusy={ isConnectButtonLoading }
disabled={ isConnectButtonLoading }
+ accessibleWhenDisabled
onClick={ () => {
setIsConnectButtonLoading( true );
@@ -85,9 +93,47 @@ export const JetpackStep: React.FC = () => {
}
);
- // Redirect to the WordPress.com connection authorization URL.
- window.location.href =
- currentStep?.actions?.auth?.href ?? '';
+ const authorizeUrl =
+ currentStep?.actions?.authorize?.href;
+ if ( ! authorizeUrl ) {
+ // Redirect to the WordPress.com connection authorization URL.
+ window.location.href =
+ currentStep?.actions?.auth?.href ?? '';
+ return;
+ }
+
+ // The store is not registered with WordPress.com yet: register it,
+ // then redirect to the authorization URL the backend returns.
+ setAuthorizeError( null );
+ speak(
+ __(
+ 'Connecting to WordPress.com. Please wait.',
+ 'woocommerce'
+ ),
+ 'polite'
+ );
+ apiFetch< { url: string } >( {
+ url: authorizeUrl,
+ method: 'POST',
+ data: {
+ source: sessionEntryPoint,
+ },
+ } )
+ .then( ( response ) => {
+ window.location.href = response.url;
+ } )
+ .catch( ( error ) => {
+ setAuthorizeError(
+ decodeEntities(
+ error?.message ||
+ __(
+ 'Something went wrong. Please try again.',
+ 'woocommerce'
+ )
+ )
+ );
+ setIsConnectButtonLoading( false );
+ } );
} }
>
{ __( 'Connect', 'woocommerce' ) }
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/wpcom-connection/test/index.test.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/wpcom-connection/test/index.test.tsx
new file mode 100644
index 00000000000..2ef896b6580
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/wpcom-connection/test/index.test.tsx
@@ -0,0 +1,174 @@
+/**
+ * External dependencies
+ */
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import React from 'react';
+import apiFetch from '@wordpress/api-fetch';
+import { speak } from '@wordpress/a11y';
+
+/**
+ * Internal dependencies
+ */
+import { useOnboardingContext } from '../../../data/onboarding-context';
+import JetpackStep from '../index';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn() } ) );
+jest.mock( '../../../data/onboarding-context', () => ( {
+ useOnboardingContext: jest.fn(),
+} ) );
+jest.mock( '~/settings-payments/utils', () => ( {
+ recordPaymentsOnboardingEvent: jest.fn(),
+} ) );
+jest.mock( '~/utils/admin-settings', () => ( {
+ WC_ASSET_URL: '',
+} ) );
+
+const mockApiFetch = apiFetch as jest.MockedFunction< typeof apiFetch >;
+
+const renderStep = (
+ actions: Record< string, { href: string } > = {
+ authorize: { href: 'https://example.com/authorize' },
+ }
+) => {
+ ( useOnboardingContext as jest.Mock ).mockReturnValue( {
+ currentStep: {
+ id: 'wpcom_connection',
+ actions,
+ },
+ closeModal: jest.fn(),
+ sessionEntryPoint: 'settings_payments',
+ } );
+
+ return render( <JetpackStep /> );
+};
+
+describe( 'JetpackStep', () => {
+ const originalLocation = window.location;
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockApiFetch.mockReset();
+ Object.defineProperty( window, 'location', {
+ writable: true,
+ value: { ...originalLocation, href: '' },
+ } );
+ } );
+
+ afterEach( () => {
+ Object.defineProperty( window, 'location', {
+ writable: true,
+ value: originalLocation,
+ } );
+ } );
+
+ it( 'redirects to the authorization URL returned after registering the store', async () => {
+ mockApiFetch.mockResolvedValue( {
+ success: true,
+ url: 'https://wordpress.com/auth',
+ } );
+ renderStep();
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Connect' } ) );
+
+ await waitFor( () =>
+ expect( window.location.href ).toBe( 'https://wordpress.com/auth' )
+ );
+ } );
+
+ it( 'redirects to the auth URL without a request when the store is already registered', () => {
+ renderStep( { auth: { href: 'https://wordpress.com/auth' } } );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Connect' } ) );
+
+ expect( mockApiFetch ).not.toHaveBeenCalled();
+ expect( window.location.href ).toBe( 'https://wordpress.com/auth' );
+ } );
+
+ it( 'decodes registration errors and lets the merchant retry', async () => {
+ mockApiFetch.mockRejectedValue( {
+ message: 'The site's URL can't be reached.',
+ } );
+ renderStep();
+ const connect = screen.getByRole( 'button', { name: 'Connect' } );
+
+ fireEvent.click( connect );
+
+ expect(
+ await screen.findByText( "The site's URL can't be reached.", {
+ selector: 'p',
+ } )
+ ).toBeInTheDocument();
+ expect( connect ).not.toBeDisabled();
+
+ fireEvent.click( connect );
+
+ await waitFor( () =>
+ expect( mockApiFetch ).toHaveBeenCalledTimes( 2 )
+ );
+ expect( mockApiFetch ).toHaveBeenLastCalledWith( {
+ url: 'https://example.com/authorize',
+ method: 'POST',
+ data: { source: 'settings_payments' },
+ } );
+ await waitFor( () => expect( connect ).not.toBeDisabled() );
+ } );
+
+ it( 'keeps Connect focused and announces progress while blocking repeated clicks', async () => {
+ let rejectRegistration: ( error: Error ) => void;
+ mockApiFetch.mockImplementation(
+ () =>
+ new Promise( ( resolve, reject ) => {
+ rejectRegistration = reject;
+ } )
+ );
+ renderStep();
+ const connect = screen.getByRole( 'button', { name: 'Connect' } );
+ connect.focus();
+
+ fireEvent.click( connect );
+
+ expect( connect ).toHaveFocus();
+ expect( connect ).toHaveAttribute( 'aria-disabled', 'true' );
+ expect( connect ).not.toBeDisabled();
+ expect( speak ).toHaveBeenCalledWith(
+ 'Connecting to WordPress.com. Please wait.',
+ 'polite'
+ );
+
+ fireEvent.click( connect );
+ expect( mockApiFetch ).toHaveBeenCalledTimes( 1 );
+
+ await act( async () => {
+ rejectRegistration( new Error( 'Registration failed.' ) );
+ } );
+
+ expect( connect ).toHaveFocus();
+ expect( connect ).not.toHaveAttribute( 'aria-disabled', 'true' );
+ expect(
+ screen.getByText( 'Registration failed.', { selector: 'p' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'shows the fallback message when authorization fails without a message', async () => {
+ mockApiFetch.mockRejectedValue( {} );
+ renderStep();
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Connect' } ) );
+
+ expect(
+ await screen.findByText(
+ 'Something went wrong. Please try again.',
+ {
+ selector: 'p',
+ }
+ )
+ ).toBeInTheDocument();
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/types.ts b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/types.ts
index bba7ca04af5..c82edd56761 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/types.ts
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/types.ts
@@ -108,6 +108,10 @@ export interface WooPaymentsProviderOnboardingStep {
type?: string;
href?: string;
};
+ authorize?: {
+ type?: string;
+ href?: string;
+ };
reset?: {
type?: string;
href?: string;
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestController.php b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestController.php
index dee47998ebb..31239458b06 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestController.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestController.php
@@ -216,6 +216,26 @@ class WooPaymentsRestController extends RestApiControllerBase {
$override
);
// Onboarding step specific routes.
+ register_rest_route(
+ $this->route_namespace,
+ '/' . $this->rest_base . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/authorize',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => fn( $request ) => $this->run( $request, 'handle_onboarding_wpcom_connection_authorize' ),
+ 'permission_callback' => fn( $request ) => $this->check_permissions( $request ),
+ 'args' => array(
+ 'source' => array(
+ 'description' => esc_html__( 'The upmost entry point from where the merchant entered the onboarding flow.', 'woocommerce' ),
+ 'type' => 'string',
+ 'required' => false,
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ ),
+ ),
+ $override
+ );
register_rest_route(
$this->route_namespace,
'/' . $this->rest_base . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT . '/init',
@@ -681,6 +701,33 @@ class WooPaymentsRestController extends RestApiControllerBase {
return rest_ensure_response( $response );
}
+ /**
+ * Handle the onboarding WPCOM connection authorize action.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return WP_Error|WP_REST_Response The response or error.
+ */
+ protected function handle_onboarding_wpcom_connection_authorize( WP_REST_Request $request ) {
+ try {
+ $result = $this->woopayments->onboarding_wpcom_connection_authorize( $request->get_param( 'source' ) );
+ } catch ( ApiException $e ) {
+ return new WP_Error(
+ $e->getErrorCode(),
+ $e->getMessage(),
+ array_merge( $e->getAdditionalData(), array( 'status' => $e->getCode() ) )
+ );
+ }
+
+ return rest_ensure_response(
+ array_merge(
+ array( 'success' => true ),
+ $result
+ )
+ );
+ }
+
/**
* Handle the onboarding test account initialize action.
*
@@ -1194,6 +1241,13 @@ class WooPaymentsRestController extends RestApiControllerBase {
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
+ 'authorize' => array(
+ 'type' => 'object',
+ 'description' => esc_html__( 'Action to register the store with WPCOM, if needed, and get the URL to authorize the WPCOM connection.', 'woocommerce' ),
+ 'properties' => $this->get_schema_properties_for_onboarding_step_action(),
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
'init' => array(
'type' => 'object',
'description' => esc_html__( 'Action to initialize a test account.', 'woocommerce' ),
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
index 9fe21c9e713..df19802ecf5 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
@@ -1654,16 +1654,9 @@ class WooPaymentsService {
// 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.
+ // Not forced: the preload runs in the background, before the merchant asks to connect.
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' )
- );
- }
+ $result = $this->register_site_with_wpcom( false );
}
return array(
@@ -1671,6 +1664,72 @@ class WooPaymentsService {
);
}
+ /**
+ * Get the URL to send the merchant to for authorizing the WPCOM/Jetpack connection.
+ *
+ * Registers the site to WPCOM first, if it is not already registered.
+ *
+ * @param string|null $source Optional. The upmost entry point from where the merchant entered the onboarding flow.
+ *
+ * @return array {
+ * @type string $url The WPCOM/Jetpack authorization URL.
+ * }
+ * @throws ApiException If the site could not be registered or the authorization URL could not be generated.
+ */
+ public function onboarding_wpcom_connection_authorize( ?string $source = null ): array {
+ $this->check_if_onboarding_action_is_acceptable();
+
+ if ( ! $this->is_store_registered_with_wpcom() && ! $this->register_site_with_wpcom( true ) ) {
+ throw new ApiException(
+ 'woocommerce_woopayments_onboarding_action_error',
+ esc_html__( 'Failed to register the store with WordPress.com.', 'woocommerce' ),
+ (int) WP_Http::INTERNAL_SERVER_ERROR
+ );
+ }
+
+ $source = $this->validate_onboarding_source( $source );
+ $wpcom_connection = $this->get_wpcom_connection_authorization( $this->get_wpcom_connection_return_url( $source ) );
+ if ( empty( $wpcom_connection['success'] ) || empty( $wpcom_connection['url'] ) ) {
+ $errors = array_values( (array) ( $wpcom_connection['errors'] ?? array() ) );
+ throw new ApiException(
+ 'woocommerce_woopayments_onboarding_action_error',
+ esc_html( (string) ( $errors[0] ?? __( 'Failed to generate the WordPress.com authorization URL.', 'woocommerce' ) ) ),
+ (int) WP_Http::INTERNAL_SERVER_ERROR
+ );
+ }
+
+ return array(
+ 'url' => $wpcom_connection['url'],
+ );
+ }
+
+ /**
+ * Register the site to WPCOM.
+ *
+ * @param bool $force Whether the merchant asked to connect. A forced attempt skips the Jetpack connection
+ * package's offline mode check and the backoff after an earlier failed attempt.
+ *
+ * @return bool The registration result.
+ * @throws ApiException If the registration failed.
+ */
+ private function register_site_with_wpcom( bool $force ): bool {
+ // Jetpack connection package versions without the `$force` parameter ignore the extra argument.
+ $result = $this->wpcom_connection_manager->try_registration( true, $force );
+ if ( is_wp_error( $result ) ) {
+ $data = map_deep( (array) $result->get_error_data(), 'esc_html' );
+ $data['registration_error_code'] = (string) $result->get_error_code();
+
+ throw new ApiException(
+ 'woocommerce_woopayments_onboarding_action_error',
+ esc_html( $result->get_error_message() ),
+ (int) WP_Http::INTERNAL_SERVER_ERROR,
+ $data // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Error details are escaped above; the registration code is a JSON lookup key.
+ );
+ }
+
+ return (bool) $result;
+ }
+
/**
* Reset onboarding.
*
@@ -2188,55 +2247,32 @@ class WooPaymentsService {
// 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.
- switch ( $source ) {
- case self::SESSION_ENTRY_LYS:
- // If the source is LYS, we return the user to the Launch Your Store flow.
- $return_url = $this->proxy->call_function(
- 'admin_url',
- 'admin.php?page=wc-admin&path=/launch-your-store' . self::ONBOARDING_PATH_BASE . '&sidebar=hub&content=payments'
- );
- break;
- default:
- // By default, we return the user to the onboarding modal in the Settings > Payments page.
- $return_url = $this->proxy->call_static(
- Utils::class,
- 'wc_payments_settings_url',
- self::ONBOARDING_PATH_BASE
- );
- break;
- }
-
- // Add standardized query arguments to the return URL.
- $return_url = add_query_arg(
- array(
- // URL query flag so we can properly identify when the user returns
- // either by accepting or rejecting the WPCOM connection.
- self::WPCOM_CONNECTION_RETURN_PARAM => '1',
- // Keep the source.
- 'source' => $source,
- // Attach the `from` parameter to more easily identify where the return request is coming from.
- 'from' => self::FROM_WPCOM,
- ),
- $return_url
- );
-
- // Try to generate the authorization URL.
- $wpcom_connection = $this->get_wpcom_connection_authorization( $return_url );
- if ( ! $wpcom_connection['success'] ) {
- // In case of errors, make sure we work with a list of error messages.
- $wpcom_step['errors'] = array_values( (array) ( $wpcom_connection['errors'] ?? array() ) );
- }
$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(
+ );
+
+ if ( $this->is_store_registered_with_wpcom() ) {
+ // The site is already registered, so generating the authorization URL will not register it.
+ $wpcom_connection = $this->get_wpcom_connection_authorization( $this->get_wpcom_connection_return_url( $source ) );
+ if ( ! $wpcom_connection['success'] ) {
+ // In case of errors, make sure we work with a list of error messages.
+ $wpcom_step['errors'] = array_values( (array) ( $wpcom_connection['errors'] ?? array() ) );
+ }
+ $wpcom_step['actions']['auth'] = array(
'type' => self::ACTION_TYPE_REDIRECT,
'href' => $wpcom_connection['url'],
- ),
- );
+ );
+ } else {
+ // Registering the site is a slow WPCOM request, so it waits for the merchant's click
+ // instead of running every time something reads the onboarding details.
+ $wpcom_step['actions']['authorize'] = array(
+ 'type' => self::ACTION_TYPE_REST,
+ 'href' => rest_url( trailingslashit( $rest_path ) . self::ONBOARDING_STEP_WPCOM_CONNECTION . '/authorize' ),
+ );
+ }
}
$steps[] = $wpcom_step;
@@ -2812,6 +2848,47 @@ class WooPaymentsService {
return $this->proxy->call_static( Utils::class, 'get_wpcom_connection_authorization', $return_url );
}
+ /**
+ * Get the URL to return the merchant to after they authorize the WPCOM (Jetpack) connection.
+ *
+ * @param string|null $source The upmost entry point from where the merchant entered the onboarding flow.
+ *
+ * @return string The return URL.
+ */
+ private function get_wpcom_connection_return_url( ?string $source ): string {
+ switch ( $source ) {
+ case self::SESSION_ENTRY_LYS:
+ // If the source is LYS, we return the user to the Launch Your Store flow.
+ $return_url = $this->proxy->call_function(
+ 'admin_url',
+ 'admin.php?page=wc-admin&path=/launch-your-store' . self::ONBOARDING_PATH_BASE . '&sidebar=hub&content=payments'
+ );
+ break;
+ default:
+ // By default, we return the user to the onboarding modal in the Settings > Payments page.
+ $return_url = $this->proxy->call_static(
+ Utils::class,
+ 'wc_payments_settings_url',
+ self::ONBOARDING_PATH_BASE
+ );
+ break;
+ }
+
+ // Add standardized query arguments to the return URL.
+ return add_query_arg(
+ array(
+ // URL query flag so we can properly identify when the user returns
+ // either by accepting or rejecting the WPCOM connection.
+ self::WPCOM_CONNECTION_RETURN_PARAM => '1',
+ // Keep the source.
+ 'source' => $source,
+ // Attach the `from` parameter to more easily identify where the return request is coming from.
+ 'from' => self::FROM_WPCOM,
+ ),
+ $return_url
+ );
+ }
+
/**
* Get the store's WPCOM (Jetpack) connection state.
*
@@ -2842,6 +2919,18 @@ class WooPaymentsService {
return $this->wpcom_connection_manager->is_connected() && $this->wpcom_connection_manager->has_connected_owner();
}
+ /**
+ * Check if the store is registered with WPCOM (blog ID and tokens are set), with or without a connected owner.
+ *
+ * @return bool Whether the store is registered with WPCOM.
+ */
+ private function is_store_registered_with_wpcom(): bool {
+ /** @var WPCOM_Connection_Manager $wpcom_connection_manager */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
+ $wpcom_connection_manager = $this->wpcom_connection_manager;
+
+ return (bool) $wpcom_connection_manager->is_connected();
+ }
+
/**
* Check if the WooPayments plugin is active.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestControllerIntegrationTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestControllerIntegrationTest.php
index cf09468a6fd..65e7cc78cf7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestControllerIntegrationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsRestControllerIntegrationTest.php
@@ -1242,6 +1242,7 @@ class WooPaymentsRestControllerIntegrationTest extends WC_Unit_Test_Case {
$this->mock_wpcom_connection_manager
->expects( $this->once() )
->method( 'try_registration' )
+ ->with( true, false )
->willReturn( true );
// Act.
@@ -1257,6 +1258,224 @@ class WooPaymentsRestControllerIntegrationTest extends WC_Unit_Test_Case {
$this->assertTrue( $data['success'] );
}
+ /**
+ * Test that the WPCOM connection authorize action registers the store and returns the authorization URL.
+ */
+ public function test_handle_onboarding_wpcom_connection_authorize() {
+ // Arrange.
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->atLeastOnce() )
+ ->method( 'is_connected' )
+ ->willReturn( false );
+ // The merchant clicked to connect, so the attempt must not be held back by an earlier failure.
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->once() )
+ ->method( 'try_registration' )
+ ->with( true, true )
+ ->willReturn( true );
+
+ // Act.
+ $request = new WP_REST_Request( 'POST', self::ENDPOINT . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/authorize' );
+ $request->set_param( 'source', 'test_source' );
+ $response = $this->server->dispatch( $request );
+
+ // Assert.
+ $this->assertSame( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertTrue( $data['success'] );
+ $this->assertSame( 'https://wordpress.com/auth?query=some_query', $data['url'] );
+ }
+
+ /**
+ * @testdox Rejects authorization without registering the store while onboarding is locked.
+ */
+ public function test_handle_onboarding_wpcom_connection_authorize_when_locked(): void {
+ update_option( WooPaymentsService::NOX_ONBOARDING_LOCKED_KEY, $this->current_time );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->never() )
+ ->method( 'try_registration' );
+
+ $request = new WP_REST_Request( 'POST', self::ENDPOINT . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/authorize' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( \WP_Http::CONFLICT, $response->get_status() );
+ $this->assertSame( 'woocommerce_woopayments_onboarding_locked', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Preserves valid sources and defaults missing or unknown authorization sources.
+ * @testWith [null, "settings_payments"]
+ * ["", "settings_payments"]
+ * ["unknown", "settings_payments"]
+ * ["settings_payments", "settings_payments"]
+ * ["lys", "lys"]
+ *
+ * @param string|null $source The requested onboarding source.
+ * @param string $expected_source The source included in the return URL.
+ */
+ public function test_handle_onboarding_wpcom_connection_authorize_source( ?string $source, string $expected_source ): void {
+ $this->mock_wpcom_connection_manager->method( 'is_connected' )->willReturn( true );
+ $this->mock_wpcom_connection_manager->expects( $this->never() )->method( 'try_registration' );
+
+ $return_url = '';
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'get_wpcom_connection_authorization' => function ( string $url ) use ( &$return_url ) {
+ $return_url = $url;
+ return array(
+ 'success' => true,
+ 'url' => 'https://wordpress.com/auth',
+ );
+ },
+ ),
+ )
+ );
+
+ $request = new WP_REST_Request( 'POST', self::ENDPOINT . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/authorize' );
+ if ( null !== $source ) {
+ $request->set_param( 'source', $source );
+ }
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( \WP_Http::OK, $response->get_status() );
+ wp_parse_str( (string) wp_parse_url( $return_url, PHP_URL_QUERY ), $query );
+ $this->assertSame( $expected_source, $query['source'] ?? null );
+ }
+
+ /**
+ * Test that the WPCOM connection authorize action reports a failed registration.
+ */
+ public function test_handle_onboarding_wpcom_connection_authorize_registration_error() {
+ // Arrange.
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->atLeastOnce() )
+ ->method( 'is_connected' )
+ ->willReturn( false );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->once() )
+ ->method( 'try_registration' )
+ ->willReturn( new \WP_Error( 'siteurl_private_ip', 'The site URL is a private IP address.' ) );
+
+ // Act.
+ $request = new WP_REST_Request( 'POST', self::ENDPOINT . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/authorize' );
+ $response = $this->server->dispatch( $request );
+
+ // Assert.
+ $this->assertSame( \WP_Http::INTERNAL_SERVER_ERROR, $response->get_status() );
+ $this->assertSame( 'The site URL is a private IP address.', $response->get_data()['message'] );
+ }
+
+ /**
+ * @testdox Rejects authorization without registering the store when WooPayments is outdated.
+ */
+ public function test_handle_onboarding_wpcom_connection_authorize_when_extension_outdated(): void {
+ Constants::set_constant( 'WCPAY_VERSION_NUMBER', '1.0.0' );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->never() )
+ ->method( 'try_registration' );
+
+ $request = new WP_REST_Request( 'POST', self::ENDPOINT . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/authorize' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( \WP_Http::FORBIDDEN, $response->get_status() );
+ $this->assertSame( 'woocommerce_woopayments_onboarding_extension_version', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Reports a failure without generating the authorization URL when registration returns false.
+ */
+ public function test_handle_onboarding_wpcom_connection_authorize_registration_returns_false(): void {
+ $this->mock_wpcom_connection_manager->method( 'is_connected' )->willReturn( false );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->once() )
+ ->method( 'try_registration' )
+ ->willReturn( false );
+ $authorization_requested = false;
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'get_wpcom_connection_authorization' => function () use ( &$authorization_requested ) {
+ $authorization_requested = true;
+ return array(
+ 'success' => true,
+ 'url' => 'https://wordpress.com/auth',
+ );
+ },
+ ),
+ )
+ );
+
+ $request = new WP_REST_Request( 'POST', self::ENDPOINT . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/authorize' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( \WP_Http::INTERNAL_SERVER_ERROR, $response->get_status() );
+ $this->assertSame( 'Failed to register the store with WordPress.com.', $response->get_data()['message'] );
+ $this->assertFalse( $authorization_requested, 'The authorization URL should not be generated for an unregistered store' );
+ }
+
+ /**
+ * @testdox Reports the first authorization error, or a fallback message, when the authorization URL cannot be generated.
+ * @testWith [{"success": false, "errors": ["Jetpack is in offline mode."]}, "Jetpack is in offline mode."]
+ * [{"success": true, "url": ""}, "Failed to generate the WordPress.com authorization URL."]
+ *
+ * @param array $authorization The result of generating the authorization URL.
+ * @param string $expected_message The expected error message.
+ */
+ public function test_handle_onboarding_wpcom_connection_authorize_authorization_error( array $authorization, string $expected_message ): void {
+ $this->mock_wpcom_connection_manager->method( 'is_connected' )->willReturn( true );
+ $this->mockable_proxy->register_static_mocks(
+ array(
+ Utils::class => array(
+ 'get_wpcom_connection_authorization' => function () use ( $authorization ) {
+ return $authorization;
+ },
+ ),
+ )
+ );
+
+ $request = new WP_REST_Request( 'POST', self::ENDPOINT . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/authorize' );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( \WP_Http::INTERNAL_SERVER_ERROR, $response->get_status() );
+ $this->assertSame( 'woocommerce_woopayments_onboarding_action_error', $response->get_data()['code'] );
+ $this->assertSame( $expected_message, $response->get_data()['message'] );
+ }
+
+ /**
+ * @testdox Returns the original registration error code and data without changing the HTTP error status.
+ * @testWith ["siteurl_private_ip"]
+ * ["wpcom_5??"]
+ *
+ * @param string $registration_error_code The Jetpack registration error code.
+ */
+ public function test_handle_onboarding_wpcom_connection_authorize_registration_error_code( string $registration_error_code ): void {
+ $this->mock_wpcom_connection_manager->method( 'is_connected' )->willReturn( false );
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->once() )
+ ->method( 'try_registration' )
+ ->willReturn(
+ new \WP_Error(
+ $registration_error_code,
+ 'Registration failed.',
+ array(
+ 'detail' => 'Diagnostic data',
+ 'status' => 400,
+ )
+ )
+ );
+
+ $request = new WP_REST_Request( 'POST', self::ENDPOINT . '/onboarding/step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/authorize' );
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( \WP_Http::INTERNAL_SERVER_ERROR, $response->get_status() );
+ $this->assertSame( 'woocommerce_woopayments_onboarding_action_error', $data['code'] );
+ $this->assertSame( $registration_error_code, $data['data']['registration_error_code'] ?? null );
+ $this->assertSame( 'Diagnostic data', $data['data']['detail'] ?? null );
+ }
+
/**
* Test onboarding reset.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
index 16bfb82a5bf..67f5cf98735 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
@@ -903,6 +903,31 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
->expects( $this->any() )
->method( 'is_connection_owner' )
->willReturn( $wpcom_connection['is_connection_owner'] );
+ // Reading the onboarding details must never register the store with WPCOM.
+ $this->mock_wpcom_connection_manager
+ ->expects( $this->never() )
+ ->method( 'try_registration' );
+ // Until the store is registered, the step offers the `authorize` action instead of an authorization URL.
+ if ( ! $wpcom_connection['is_store_connected'] ) {
+ $expected_steps[1]['actions'] = array(
+ 'start' => array(
+ 'type' => WooPaymentsService::ACTION_TYPE_REST,
+ 'href' => rest_url( $rest_path . 'step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/start' ),
+ ),
+ 'authorize' => array(
+ 'type' => WooPaymentsService::ACTION_TYPE_REST,
+ 'href' => rest_url( $rest_path . 'step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/authorize' ),
+ ),
+ 'check' => array(
+ 'type' => WooPaymentsService::ACTION_TYPE_REST,
+ 'href' => rest_url( $rest_path . 'step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/check' ),
+ ),
+ 'clean' => array(
+ 'type' => WooPaymentsService::ACTION_TYPE_REST,
+ 'href' => rest_url( $rest_path . 'step/' . WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION . '/clean' ),
+ ),
+ );
+ }
// If the status is completed, we expect only the general actions.
if ( WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED === $expected_step_statuses[ WooPaymentsService::ONBOARDING_STEP_WPCOM_CONNECTION ] ) {
$expected_steps[1]['actions'] = array(
@@ -10624,9 +10649,11 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
->expects( $this->any() )
->method( 'is_connected' )
->willReturn( false );
+ // The preload runs in the background, so the attempt is not forced.
$this->mock_wpcom_connection_manager
->expects( $this->once() )
->method( 'try_registration' )
+ ->with( true, false )
->willReturn( $expected_wpcom_registration_result );
// Arrange the onboarding locked DB option.