Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.53% covered (success)
91.53%
281 / 307
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Partner_Onboarding
92.13% covered (success)
92.13%
281 / 305
66.67% covered (warning)
66.67%
6 / 9
58.58
0.00% covered (danger)
0.00%
0 / 1
 generate_seller_nonce
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_partner_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_partner_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_merchant_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generate_signup_link
98.50% covered (success)
98.50%
131 / 133
0.00% covered (danger)
0.00%
0 / 1
28
 complete_onboarding
82.30% covered (warning)
82.30%
93 / 113
0.00% covered (danger)
0.00%
0 / 1
18.60
 abandon_onboarding
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 check_merchant_status
95.74% covered (success)
95.74%
45 / 47
0.00% covered (danger)
0.00%
0 / 1
6
 cleanup
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * PayPal Partner Referrals onboarding handler.
4 *
5 * Implements the "Connect with PayPal" flow using PayPal's Partner Referrals
6 * API (v2). Generates signup links, exchanges auth codes for credentials,
7 * and verifies merchant integration status.
8 *
9 * @package automattic/jetpack-paypal-payments
10 * @since 0.9.0
11 * @see https://developer.paypal.com/docs/multiparty/seller-onboarding/build-onboarding/
12 */
13
14namespace Automattic\Jetpack\PaypalPayments;
15
16use Automattic\Jetpack\Connection\Client;
17
18if ( ! defined( 'ABSPATH' ) ) {
19    exit;
20}
21
22/**
23 * Class PayPal_Partner_Onboarding
24 *
25 * Handles the Partner Referrals onboarding flow for one-click
26 * "Connect with PayPal" merchant setup.
27 */
28class PayPal_Partner_Onboarding {
29
30    /**
31     * WordPress.com proxy route that creates the Partner Referral.
32     *
33     * Automattic's PayPal platform credentials live on WordPress.com, so the
34     * referral is created there rather than from the site.
35     *
36     * @var string
37     */
38    const WPCOM_SIGNUP_LINK_ROUTE = '/paypal/platform/signup-link';
39
40    /**
41     * OAuth token endpoint for authorization code exchange.
42     *
43     * @var string
44     */
45    const TOKEN_ENDPOINT = '/v1/oauth2/token';
46
47    /**
48     * Merchant integrations endpoint template.
49     * Replace {partner_id} and {merchant_id} at call time.
50     *
51     * @var string
52     */
53    const MERCHANT_INTEGRATIONS_ENDPOINT = '/v1/customer/partners/%s/merchant-integrations/%s';
54
55    /**
56     * Merchant credentials endpoint template.
57     * Replace {partner_id} at call time.
58     *
59     * @var string
60     */
61    const MERCHANT_CREDENTIALS_ENDPOINT = '/v1/customer/partners/%s/merchant-integrations/credentials/';
62
63    /**
64     * Transient key for storing the seller nonce used during onboarding.
65     * Stored as a transient with 30-minute TTL so abandoned flows auto-expire.
66     *
67     * @var string
68     */
69    const SELLER_NONCE_TRANSIENT_KEY = 'jetpack_paypal_payment_buttons_seller_nonce';
70
71    /**
72     * Option key for storing the partner merchant ID.
73     *
74     * @var string
75     */
76    const PARTNER_ID_OPTION_KEY = 'jetpack_paypal_payment_buttons_partner_id';
77
78    /**
79     * Option key for storing the onboarded merchant's PayPal merchant ID.
80     *
81     * @var string
82     */
83    const MERCHANT_ID_OPTION_KEY = 'jetpack_paypal_payment_buttons_merchant_id';
84
85    /**
86     * Option key for storing the onboarding method used.
87     *
88     * @var string
89     */
90    const ONBOARDING_METHOD_OPTION_KEY = 'jetpack_paypal_payment_buttons_onboarding_method';
91
92    /**
93     * Products to request during onboarding.
94     *
95     * @var array
96     */
97    const ONBOARDING_PRODUCTS = array( 'EXPRESS_CHECKOUT' );
98
99    /**
100     * Features to request during onboarding.
101     *
102     * PAYMENT_LINKS_AND_BUTTONS grants the onboarded seller's app access to
103     * /v1/checkout/payment-resources - the endpoint every button here is created
104     * through. Neither EXPRESS_CHECKOUT nor PPCP includes it, and PayPal does not
105     * document the valid feature values, so it has to be requested by name.
106     *
107     * @var array
108     */
109    const ONBOARDING_FEATURES = array( 'PAYMENT', 'REFUND', 'ACCESS_MERCHANT_INFORMATION', 'PAYMENT_LINKS_AND_BUTTONS' );
110
111    /**
112     * Generate a seller nonce for the onboarding flow.
113     *
114     * This is the PKCE code verifier. PayPal's schema requires 44-128 characters
115     * matching `^[a-zA-Z0-9-_:]+$`. Note that the prose in PayPal's own field
116     * description says "43-128", but `minLength` is 44 and the API rejects 43
117     * with "Request is not well-formed, syntactically incorrect, or violates
118     * schema." 32 random bytes base64url-encode to exactly 43 characters, which
119     * is why every referral request failed. 48 bytes give exactly 64 characters,
120     * with no padding to strip.
121     *
122     * @return string The generated nonce.
123     */
124    private static function generate_seller_nonce() {
125        $bytes = random_bytes( 48 );
126        return rtrim( strtr( base64_encode( $bytes ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Generating URL-safe nonce for PayPal Partner Referrals.
127    }
128
129    /**
130     * Get the partner merchant ID for the current environment.
131     *
132     * @return string The partner merchant ID.
133     */
134    public static function get_partner_id() {
135        return get_option( self::PARTNER_ID_OPTION_KEY, '' );
136    }
137
138    /**
139     * Set the partner merchant ID.
140     *
141     * @param string $partner_id The Automattic partner merchant ID.
142     * @return bool True on success.
143     */
144    public static function set_partner_id( $partner_id ) {
145        return update_option( self::PARTNER_ID_OPTION_KEY, sanitize_text_field( $partner_id ), false );
146    }
147
148    /**
149     * Get the onboarded merchant's PayPal merchant ID.
150     *
151     * @return string The merchant ID, or empty string if not onboarded.
152     */
153    public static function get_merchant_id() {
154        return get_option( self::MERCHANT_ID_OPTION_KEY, '' );
155    }
156
157    /**
158     * Generate a Partner Referrals signup link for the merchant.
159     *
160     * The referral itself is created by WordPress.com, which holds Automattic's
161     * PayPal platform credentials; this method builds the referral body, proxies it
162     * through wpcom/v2/paypal/platform/signup-link using the site's blog token,
163     * and returns the action_url for the PayPal mini-browser lightbox.
164     *
165     * The seller nonce stays on the site: it is the PKCE code_verifier that
166     * complete_onboarding() needs when it exchanges the auth code.
167     *
168     * Prerequisite: the site must be connected to WordPress.com.
169     *
170     * @param string $return_url  The URL PayPal redirects to after onboarding.
171     * @param string $environment 'sandbox' or 'production'.
172     * @return array|\WP_Error Array with 'action_url', 'referral_id' and 'tracking_id', or WP_Error.
173     */
174    public static function generate_signup_link( $return_url, $environment = 'production' ) {
175        // Enforce HTTPS on the return URL to protect the auth code in transit.
176        if ( 'production' === $environment && 0 !== strpos( $return_url, 'https://' ) ) {
177            return new \WP_Error(
178                'paypal_onboarding_insecure_url',
179                __( 'The return URL must use HTTPS for production onboarding.', 'jetpack-paypal-payments' ),
180                array( 'status' => 400 )
181            );
182        }
183
184        /*
185         * complete_onboarding() exchanges the auth code against whichever base URL
186         * the stored environment names, so it has to be the one the referral was
187         * created for. Without this a sandbox referral is exchanged against
188         * production and PayPal answers "Client Authentication failed".
189         */
190        PayPal_OAuth::set_environment( $environment );
191
192        // Generate and store an encrypted seller nonce for the auth code exchange.
193        // Uses a 30-minute transient so abandoned onboarding flows auto-expire.
194        $seller_nonce    = self::generate_seller_nonce();
195        $encrypted_nonce = PayPal_OAuth::encrypt( $seller_nonce );
196        if ( is_wp_error( $encrypted_nonce ) ) {
197            return $encrypted_nonce;
198        }
199        set_transient( self::SELLER_NONCE_TRANSIENT_KEY, $encrypted_nonce, 30 * MINUTE_IN_SECONDS );
200
201        // Build the tracking ID from the site URL for uniqueness.
202        $tracking_id = 'woo-ncps-' . substr( md5( get_site_url() ), 0, 12 ) . '-' . time();
203
204        $request_body = array(
205            'tracking_id'             => $tracking_id,
206            'partner_config_override' => array(
207                'return_url'             => $return_url,
208                'return_url_description' => __( 'Return to your WordPress site to complete setup.', 'jetpack-paypal-payments' ),
209                'show_add_credit_card'   => true,
210            ),
211            'operations'              => array(
212                array(
213                    'operation'                  => 'API_INTEGRATION',
214                    'api_integration_preference' => array(
215                        'rest_api_integration' => array(
216                            'integration_method'  => 'PAYPAL',
217                            'integration_type'    => 'FIRST_PARTY',
218                            'first_party_details' => array(
219                                'features'     => self::ONBOARDING_FEATURES,
220                                'seller_nonce' => $seller_nonce,
221                            ),
222                        ),
223                    ),
224                ),
225            ),
226            'products'                => self::ONBOARDING_PRODUCTS,
227            'legal_consents'          => array(
228                array(
229                    'type'    => 'SHARE_DATA_CONSENT',
230                    'granted' => true,
231                ),
232            ),
233        );
234
235        // Automattic's PayPal platform credentials live on WordPress.com, so the
236        // referral is created there and only the resulting URL comes back here.
237        $response = Client::wpcom_json_api_request_as_blog(
238            self::WPCOM_SIGNUP_LINK_ROUTE,
239            '2',
240            array(
241                'method'  => 'POST',
242                'timeout' => 30,
243                'headers' => array(
244                    'Content-Type' => 'application/json',
245                    'Accept'       => 'application/json',
246                ),
247            ),
248            wp_json_encode(
249                array(
250                    'environment' => $environment,
251                    'referral'    => $request_body,
252                ),
253                JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
254            ),
255            'wpcom'
256        );
257
258        if ( is_wp_error( $response ) ) {
259            return new \WP_Error(
260                'paypal_referral_request_failed',
261                sprintf(
262                    /* translators: %s: error message */
263                    __( 'Failed to create PayPal onboarding link: %s', 'jetpack-paypal-payments' ),
264                    $response->get_error_message()
265                )
266            );
267        }
268
269        $status_code = wp_remote_retrieve_response_code( $response );
270        $body        = json_decode( wp_remote_retrieve_body( $response ), true );
271
272        if ( 201 !== $status_code && 200 !== $status_code ) {
273            /*
274             * Keep the merchant-facing message actionable, but carry PayPal's own
275             * diagnostics (the offending field, the issue code, and the debug ID
276             * PayPal support traces on) through in the error data. Without them a
277             * rejected referral is just a 400 with a generic sentence.
278             */
279            $error_data = array( 'status' => $status_code );
280
281            foreach ( array( 'paypal_error', 'paypal_details', 'paypal_debug_id' ) as $key ) {
282                if ( isset( $body['data'][ $key ] ) ) {
283                    $error_data[ $key ] = $body['data'][ $key ];
284                }
285            }
286
287            if ( ! empty( $body['code'] ) ) {
288                $error_data['platform_error_code'] = $body['code'];
289            }
290
291            if ( ! empty( $body['message'] ) ) {
292                $error_data['paypal_message'] = $body['message'];
293            }
294
295            /*
296             * A `platform_*` code means WordPress.com is missing or misconfigured
297             * the PayPal platform credentials. Retrying will never clear that, so
298             * telling the merchant to try again sends them in circles and hides
299             * the one message that says what to fix. Pass it through instead.
300             */
301            $is_platform_misconfiguration = ! empty( $body['code'] )
302                && 0 === strpos( (string) $body['code'], 'platform_' );
303
304            if ( $is_platform_misconfiguration && ! empty( $body['message'] ) ) {
305                return new \WP_Error(
306                    'paypal_referral_failed',
307                    sanitize_text_field( $body['message'] ),
308                    $error_data
309                );
310            }
311
312            $message = __( 'Could not create a PayPal onboarding link. Please try again or use the manual credentials option.', 'jetpack-paypal-payments' );
313
314            // PayPal's generic top-level sentence never says what was rejected;
315            // its `details` entries name the field and issue, so show those.
316            $issues = array();
317            if ( ! empty( $error_data['paypal_details'] ) && is_array( $error_data['paypal_details'] ) ) {
318                foreach ( $error_data['paypal_details'] as $detail ) {
319                    if ( ! is_array( $detail ) ) {
320                        continue;
321                    }
322                    $issue = trim(
323                        implode(
324                            ' ',
325                            array_filter(
326                                array(
327                                    isset( $detail['field'] ) ? sanitize_text_field( $detail['field'] ) : '',
328                                    isset( $detail['issue'] ) ? sanitize_text_field( $detail['issue'] ) : '',
329                                    isset( $detail['description'] ) ? sanitize_text_field( $detail['description'] ) : '',
330                                )
331                            )
332                        )
333                    );
334                    if ( '' !== $issue ) {
335                        $issues[] = $issue;
336                    }
337                }
338            }
339
340            if ( $issues || ! empty( $error_data['paypal_debug_id'] ) ) {
341                $message .= ' ' . sprintf(
342                    /* translators: 1: what PayPal rejected, 2: PayPal's debug ID. */
343                    __( 'PayPal reported: %1$s (debug ID %2$s).', 'jetpack-paypal-payments' ),
344                    $issues ? implode( '; ', $issues ) : __( 'unknown error', 'jetpack-paypal-payments' ),
345                    ! empty( $error_data['paypal_debug_id'] ) ? sanitize_text_field( $error_data['paypal_debug_id'] ) : '—'
346                );
347            }
348
349            return new \WP_Error(
350                'paypal_referral_failed',
351                $message,
352                $error_data
353            );
354        }
355
356        if ( empty( $body['action_url'] ) ) {
357            return new \WP_Error(
358                'paypal_referral_no_url',
359                __( 'PayPal returned a successful response but no onboarding URL was included.', 'jetpack-paypal-payments' )
360            );
361        }
362
363        // The auth code exchange and the status check both address PayPal as the
364        // partner, so store the partner merchant ID WordPress.com used.
365        if ( ! empty( $body['partner_merchant_id'] ) ) {
366            self::set_partner_id( $body['partner_merchant_id'] );
367        }
368
369        return array(
370            'action_url'  => $body['action_url'],
371            'referral_id' => $body['referral_id'] ?? '',
372            'tracking_id' => $tracking_id,
373        );
374    }
375
376    /**
377     * Exchange an authorization code for merchant credentials.
378     *
379     * Called after the merchant completes the PayPal onboarding flow.
380     * Uses the seller_nonce as code_verifier for PKCE.
381     *
382     * @param string $auth_code  The authorization code from PayPal callback.
383     * @param string $shared_id  The shared ID from the onboarding callback.
384     * @param string $merchant_id_in_paypal The merchant's PayPal payer ID.
385     * @return true|\WP_Error True on success, WP_Error on failure.
386     */
387    public static function complete_onboarding( $auth_code, $shared_id, $merchant_id_in_paypal ) {
388        $encrypted_nonce = get_transient( self::SELLER_NONCE_TRANSIENT_KEY );
389        if ( empty( $encrypted_nonce ) ) {
390            return new \WP_Error(
391                'paypal_onboarding_no_nonce',
392                __( 'Onboarding session expired. Please try connecting again.', 'jetpack-paypal-payments' )
393            );
394        }
395
396        $seller_nonce = PayPal_OAuth::decrypt( $encrypted_nonce );
397        if ( false === $seller_nonce ) {
398            delete_transient( self::SELLER_NONCE_TRANSIENT_KEY );
399            return new \WP_Error(
400                'paypal_onboarding_nonce_corrupt',
401                __( 'Onboarding session data could not be read. Please try connecting again.', 'jetpack-paypal-payments' )
402            );
403        }
404
405        $base_url = PayPal_OAuth::get_base_url();
406
407        // Step 1: Exchange auth code for access token using shared_id as client_id.
408        $token_response = wp_remote_post(
409            $base_url . self::TOKEN_ENDPOINT,
410            array(
411                'timeout' => 30,
412                'headers' => array(
413                    'Authorization' => 'Basic ' . base64_encode( $shared_id . ':' ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required by PayPal OAuth spec: shared_id with empty password.
414                    'Content-Type'  => 'application/x-www-form-urlencoded',
415                    'Accept'        => 'application/json',
416                ),
417                'body'    => http_build_query(
418                    array(
419                        'grant_type'    => 'authorization_code',
420                        'code'          => $auth_code,
421                        'code_verifier' => $seller_nonce,
422                    )
423                ),
424            )
425        );
426
427        if ( is_wp_error( $token_response ) ) {
428            return new \WP_Error(
429                'paypal_onboarding_token_failed',
430                sprintf(
431                    /* translators: %s: error message */
432                    __( 'Failed to exchange PayPal authorization code: %s', 'jetpack-paypal-payments' ),
433                    $token_response->get_error_message()
434                )
435            );
436        }
437
438        $token_status = wp_remote_retrieve_response_code( $token_response );
439        $token_data   = json_decode( wp_remote_retrieve_body( $token_response ), true );
440
441        if ( 200 !== $token_status || empty( $token_data['access_token'] ) ) {
442            $error_msg = $token_data['error_description'] ?? __( 'Unknown error during token exchange', 'jetpack-paypal-payments' );
443            return new \WP_Error(
444                'paypal_onboarding_token_error',
445                sprintf(
446                    /* translators: %s: error description */
447                    __( 'PayPal token exchange failed: %s', 'jetpack-paypal-payments' ),
448                    sanitize_text_field( $error_msg )
449                ),
450                array( 'status' => $token_status )
451            );
452        }
453
454        $seller_access_token = $token_data['access_token'];
455
456        // Step 2: Fetch merchant credentials using the seller's access token.
457        $partner_id = self::get_partner_id();
458        $creds_url  = $base_url . sprintf( self::MERCHANT_CREDENTIALS_ENDPOINT, $partner_id );
459
460        $creds_response = wp_remote_get(
461            $creds_url,
462            array(
463                'timeout' => 30,
464                'headers' => array(
465                    'Authorization' => 'Bearer ' . $seller_access_token,
466                    'Content-Type'  => 'application/json',
467                    'Accept'        => 'application/json',
468                ),
469            )
470        );
471
472        if ( is_wp_error( $creds_response ) ) {
473            return new \WP_Error(
474                'paypal_onboarding_creds_failed',
475                sprintf(
476                    /* translators: %s: error message */
477                    __( 'Failed to retrieve merchant credentials: %s', 'jetpack-paypal-payments' ),
478                    $creds_response->get_error_message()
479                )
480            );
481        }
482
483        $creds_status = wp_remote_retrieve_response_code( $creds_response );
484        $creds_data   = json_decode( wp_remote_retrieve_body( $creds_response ), true );
485
486        if ( 200 !== $creds_status || empty( $creds_data['client_id'] ) || empty( $creds_data['client_secret'] ) ) {
487            return new \WP_Error(
488                'paypal_onboarding_creds_error',
489                __( 'PayPal returned merchant credentials in an unexpected format. Please try connecting again.', 'jetpack-paypal-payments' ),
490                array( 'status' => $creds_status )
491            );
492        }
493
494        // Step 3: Store the credentials using the existing encrypted storage.
495        $stored = PayPal_OAuth::store_credentials(
496            $creds_data['client_id'],
497            $creds_data['client_secret']
498        );
499
500        if ( ! $stored || is_wp_error( $stored ) ) {
501            return new \WP_Error(
502                'paypal_onboarding_storage_failed',
503                __( 'Failed to store PayPal credentials. Please ensure your WordPress installation supports encryption.', 'jetpack-paypal-payments' ),
504                array( 'status' => 500 )
505            );
506        }
507
508        /*
509         * Resolve the merchant ID.
510         *
511         * PayPal delivers `merchantIdInPayPal` as a query parameter on the return
512         * URL, while `authCode` and `sharedId` arrive by postMessage. A caller
513         * that only reads the postMessage has no merchant ID to pass, so prefer
514         * the `payer_id` PayPal just returned with the credentials — it is the
515         * same value, comes straight from the API, and PayPal recommends it as
516         * the way to identify a merchant.
517         *
518         * Storing an empty value here is what left the site "connected" but
519         * unable to report its own merchant status.
520         */
521        $merchant_id = sanitize_text_field( $merchant_id_in_paypal );
522        if ( '' === $merchant_id && ! empty( $creds_data['payer_id'] ) ) {
523            $merchant_id = sanitize_text_field( $creds_data['payer_id'] );
524        }
525
526        if ( '' === $merchant_id ) {
527            return self::abandon_onboarding(
528                new \WP_Error(
529                    'paypal_onboarding_no_merchant_id',
530                    __( 'PayPal did not return a merchant ID for this account. Please try connecting again.', 'jetpack-paypal-payments' ),
531                    array( 'status' => 502 )
532                )
533            );
534        }
535
536        // Store the merchant ID and onboarding method.
537        update_option( self::MERCHANT_ID_OPTION_KEY, $merchant_id, false );
538        update_option( self::ONBOARDING_METHOD_OPTION_KEY, 'partner_referrals', false );
539
540        // Clean up the seller nonce — it's single-use.
541        delete_transient( self::SELLER_NONCE_TRANSIENT_KEY );
542
543        // Step 4: Validate that the credentials work and the API is accessible.
544        $validation = PayPal_OAuth::validate_credentials();
545        if ( is_wp_error( $validation ) ) {
546            return self::abandon_onboarding( $validation );
547        }
548
549        $api_access = PayPal_OAuth::validate_api_access();
550        if ( is_wp_error( $api_access ) ) {
551            return self::abandon_onboarding( $api_access );
552        }
553
554        return true;
555    }
556
557    /**
558     * Discard a half-finished connection and hand back the reason it failed.
559     *
560     * The credentials are stored before they are validated, so a failure past
561     * that point would otherwise leave the site connected while the editor
562     * reports an error -- and the merchant is told to reconnect an account that
563     * every other screen already treats as connected.
564     *
565     * @param \WP_Error $error Why onboarding was abandoned.
566     * @return \WP_Error The same error, once the partial state is gone.
567     */
568    private static function abandon_onboarding( $error ) {
569        PayPal_OAuth::delete_credentials();
570        delete_option( self::MERCHANT_ID_OPTION_KEY );
571        delete_option( self::ONBOARDING_METHOD_OPTION_KEY );
572
573        return $error;
574    }
575
576    /**
577     * Check the merchant's integration status with PayPal.
578     *
579     * Verifies that the merchant can receive payments and has confirmed email.
580     *
581     * @return array|\WP_Error Integration status array, or WP_Error.
582     */
583    public static function check_merchant_status() {
584        $partner_id  = self::get_partner_id();
585        $merchant_id = self::get_merchant_id();
586
587        if ( empty( $partner_id ) || empty( $merchant_id ) ) {
588            // Name the missing half. These are stored at different points in the
589            // flow -- the partner ID when the signup link is created, the merchant
590            // ID when onboarding completes -- so which one is absent says where
591            // the flow broke.
592            return new \WP_Error(
593                'paypal_no_merchant_info',
594                __( 'Merchant integration info not available. Please reconnect your PayPal account.', 'jetpack-paypal-payments' ),
595                array(
596                    'status'          => 400,
597                    'has_partner_id'  => ! empty( $partner_id ),
598                    'has_merchant_id' => ! empty( $merchant_id ),
599                )
600            );
601        }
602
603        $token = PayPal_OAuth::get_access_token();
604        if ( is_wp_error( $token ) ) {
605            return $token;
606        }
607
608        $url = PayPal_OAuth::get_base_url() . sprintf(
609            self::MERCHANT_INTEGRATIONS_ENDPOINT,
610            $partner_id,
611            $merchant_id
612        );
613
614        $response = wp_remote_get(
615            $url,
616            array(
617                'timeout' => 15,
618                'headers' => array(
619                    'Authorization' => 'Bearer ' . $token,
620                    'Content-Type'  => 'application/json',
621                    'Accept'        => 'application/json',
622                ),
623            )
624        );
625
626        if ( is_wp_error( $response ) ) {
627            return $response;
628        }
629
630        $status_code = wp_remote_retrieve_response_code( $response );
631        $data        = json_decode( wp_remote_retrieve_body( $response ), true );
632
633        if ( 200 !== $status_code ) {
634            return new \WP_Error(
635                'paypal_merchant_status_error',
636                __( 'Could not retrieve merchant integration status from PayPal.', 'jetpack-paypal-payments' ),
637                array( 'status' => $status_code )
638            );
639        }
640
641        return array(
642            'merchant_id'             => $merchant_id,
643            'payments_receivable'     => ! empty( $data['payments_receivable'] ),
644            'primary_email_confirmed' => ! empty( $data['primary_email_confirmed'] ),
645            'products'                => $data['products'] ?? array(),
646        );
647    }
648
649    /**
650     * Clean up all Partner Referrals onboarding data.
651     *
652     * Called during disconnect to remove merchant ID, partner ID, and nonce.
653     *
654     * @return void
655     */
656    public static function cleanup() {
657        delete_transient( self::SELLER_NONCE_TRANSIENT_KEY );
658        delete_option( self::MERCHANT_ID_OPTION_KEY );
659        delete_option( self::ONBOARDING_METHOD_OPTION_KEY );
660        // Note: Partner ID is not deleted — it's a site-level config, not per-merchant.
661    }
662}