Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.93% covered (danger)
44.93%
102 / 227
38.89% covered (danger)
38.89%
7 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_OAuth
45.33% covered (danger)
45.33%
102 / 225
38.89% covered (danger)
38.89%
7 / 18
918.90
0.00% covered (danger)
0.00%
0 / 1
 get_environment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_environment
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_base_url
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_encryption_key
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
 encrypt
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 decrypt
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
5.39
 store_credentials
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
5.03
 get_credentials
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
7.01
 has_credentials
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete_credentials
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_access_token
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
7.33
 request_access_token_with_lock
50.00% covered (danger)
50.00%
7 / 14
0.00% covered (danger)
0.00%
0 / 1
10.50
 request_access_token
10.00% covered (danger)
10.00%
6 / 60
0.00% covered (danger)
0.00%
0 / 1
82.90
 clear_cached_token
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validate_credentials
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 validate_api_access
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
156
 get_connection_status
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
4.06
 disconnect
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * PayPal OAuth 2.0 client credentials authentication handler.
4 *
5 * Manages OAuth token exchange, credential storage, and token caching
6 * for the PayPal Pay Links & Buttons API integration.
7 *
8 * @package automattic/jetpack-paypal-payments
9 * @since 0.7.0
10 */
11
12namespace Automattic\Jetpack\PaypalPayments;
13
14use Automattic\Jetpack\Connection\Manager;
15use Automattic\Jetpack\Status\Host;
16
17if ( ! defined( 'ABSPATH' ) ) {
18    exit;
19}
20
21/**
22 * Class PayPal_OAuth
23 *
24 * Handles PayPal OAuth 2.0 client credentials grant flow.
25 * Stores encrypted credentials in wp_options and caches
26 * access tokens using WordPress transients.
27 */
28class PayPal_OAuth {
29
30    /**
31     * Request-scoped cache for decrypted credentials.
32     * Prevents redundant sodium_crypto_secretbox_open calls within a single request.
33     *
34     * @var array|false|null Null = not yet fetched, false = no credentials, array = cached.
35     */
36    private static $credentials_cache = null;
37
38    /**
39     * Option key for storing encrypted PayPal client credentials.
40     *
41     * @var string
42     */
43    const CREDENTIALS_OPTION_KEY = 'jetpack_paypal_payment_buttons_credentials';
44
45    /**
46     * Transient key for caching the OAuth access token.
47     *
48     * @var string
49     */
50    const TOKEN_TRANSIENT_KEY = 'jetpack_paypal_payment_buttons_token';
51
52    /**
53     * Option key for the environment setting (sandbox or production).
54     *
55     * @var string
56     */
57    const ENVIRONMENT_OPTION_KEY = 'jetpack_paypal_payment_buttons_environment';
58
59    /**
60     * PayPal sandbox API base URL.
61     *
62     * @var string
63     */
64    const SANDBOX_BASE_URL = 'https://api-m.sandbox.paypal.com';
65
66    /**
67     * PayPal production API base URL.
68     *
69     * @var string
70     */
71    const PRODUCTION_BASE_URL = 'https://api.paypal.com';
72
73    /**
74     * OAuth token endpoint path.
75     *
76     * @var string
77     */
78    const TOKEN_ENDPOINT = '/v1/oauth2/token';
79
80    /**
81     * Buffer in seconds to subtract from token expiry for early refresh.
82     * Refreshes 5 minutes before actual expiry to prevent edge-case failures.
83     *
84     * @var int
85     */
86    const TOKEN_EXPIRY_BUFFER = 300;
87
88    /**
89     * Option key for storing the absolute token expiry timestamp.
90     *
91     * Provides a reliable expiry check independent of the transient cache,
92     * which can be evicted by object caches or plugin flushes.
93     *
94     * @var string
95     */
96    const TOKEN_EXPIRES_AT_OPTION_KEY = 'jetpack_paypal_payment_buttons_token_expires_at';
97
98    /**
99     * Get the current PayPal API environment.
100     *
101     * @return string 'sandbox' or 'production'. Defaults to 'production'.
102     */
103    public static function get_environment() {
104        return get_option( self::ENVIRONMENT_OPTION_KEY, 'production' );
105    }
106
107    /**
108     * Set the PayPal API environment.
109     *
110     * @param string $environment Either 'sandbox' or 'production'.
111     * @return bool True if the option was updated, false otherwise.
112     */
113    public static function set_environment( $environment ) {
114        $environment = sanitize_text_field( $environment );
115
116        if ( ! in_array( $environment, array( 'sandbox', 'production' ), true ) ) {
117            return false;
118        }
119
120        // Clear cached token when environment changes.
121        self::clear_cached_token();
122
123        return update_option( self::ENVIRONMENT_OPTION_KEY, $environment, false );
124    }
125
126    /**
127     * Get the PayPal API base URL for the current environment.
128     *
129     * @return string The base URL (no trailing slash).
130     */
131    public static function get_base_url() {
132        return 'production' === self::get_environment()
133            ? self::PRODUCTION_BASE_URL
134            : self::SANDBOX_BASE_URL;
135    }
136
137    /**
138     * Derive a symmetric encryption key from AUTH_KEY.
139     *
140     * Uses sodium_crypto_generichash (BLAKE2b) to derive a fixed-length
141     * key suitable for sodium_crypto_secretbox from the WordPress AUTH_KEY
142     * constant defined in wp-config.php.
143     *
144     * @return string|\WP_Error Raw binary key of SODIUM_CRYPTO_SECRETBOX_KEYBYTES length, or WP_Error if AUTH_KEY is unusable.
145     */
146    private static function get_encryption_key() {
147        if ( ! defined( 'AUTH_KEY' ) || '' === \AUTH_KEY || 'put your unique phrase here' === \AUTH_KEY ) {
148            return new \WP_Error(
149                'weak_encryption_key',
150                __( 'Your site\'s AUTH_KEY is not configured. Please set unique security keys in wp-config.php before connecting PayPal. Visit https://api.wordpress.org/secret-key/1.1/salt/ to generate them.', 'jetpack-paypal-payments' )
151            );
152        }
153
154        return sodium_crypto_generichash( \AUTH_KEY, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
155    }
156
157    /**
158     * Encrypt a plaintext string using sodium_crypto_secretbox.
159     *
160     * Returns a base64-encoded string containing the nonce prepended to the ciphertext.
161     *
162     * @param string $plaintext The string to encrypt.
163     * @return string|\WP_Error Base64-encoded nonce + ciphertext, or WP_Error if the encryption key is unavailable.
164     */
165    public static function encrypt( $plaintext ) {
166        $key = self::get_encryption_key();
167        if ( is_wp_error( $key ) ) {
168            return $key;
169        }
170        $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
171
172        $ciphertext = sodium_crypto_secretbox( $plaintext, $nonce, $key );
173
174        return base64_encode( $nonce . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encoding binary ciphertext for safe storage in wp_options.
175    }
176
177    /**
178     * Decrypt a string previously encrypted with self::encrypt().
179     *
180     * @param string $encoded Base64-encoded nonce + ciphertext.
181     * @return string|false The decrypted plaintext, or false on failure.
182     */
183    public static function decrypt( $encoded ) {
184        $decoded = base64_decode( $encoded, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding binary ciphertext from wp_options storage.
185
186        if ( false === $decoded || strlen( $decoded ) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES ) {
187            return false;
188        }
189
190        $nonce      = substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
191        $ciphertext = substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
192        $key        = self::get_encryption_key();
193        if ( is_wp_error( $key ) ) {
194            return false;
195        }
196
197        try {
198            $plaintext = sodium_crypto_secretbox_open( $ciphertext, $nonce, $key );
199        } catch ( \SodiumException $e ) {
200            return false;
201        }
202
203        return $plaintext;
204    }
205
206    /**
207     * Store PayPal client credentials.
208     *
209     * Credentials are encrypted at rest using sodium_crypto_secretbox
210     * (XSalsa20-Poly1305 authenticated encryption) with a key derived
211     * from AUTH_KEY via BLAKE2b. Each storage operation generates a
212     * fresh random nonce.
213     *
214     * @param string $client_id     The PayPal OAuth client ID.
215     * @param string $client_secret The PayPal OAuth client secret.
216     * @return bool|\WP_Error True on success, false on empty input, WP_Error on encryption failure.
217     */
218    public static function store_credentials( $client_id, $client_secret ) {
219        // sanitize_text_field() is avoided here because it may strip valid OAuth
220        // credential characters (+, /, =). wp_strip_all_tags() leaves those
221        // intact while still removing markup, which a real PayPal credential
222        // never contains.
223        $client_id     = trim( wp_strip_all_tags( wp_unslash( $client_id ) ) );
224        $client_secret = trim( wp_strip_all_tags( wp_unslash( $client_secret ) ) );
225
226        if ( empty( $client_id ) || empty( $client_secret ) ) {
227            return false;
228        }
229
230        $encrypted_id     = self::encrypt( $client_id );
231        $encrypted_secret = self::encrypt( $client_secret );
232
233        // If encryption failed due to weak AUTH_KEY, propagate the error.
234        if ( is_wp_error( $encrypted_id ) ) {
235            return $encrypted_id;
236        }
237        if ( is_wp_error( $encrypted_secret ) ) {
238            return $encrypted_secret;
239        }
240
241        $credentials = array(
242            'encrypted_client_id'     => $encrypted_id,
243            'encrypted_client_secret' => $encrypted_secret,
244            'stored_at'               => time(),
245        );
246
247        // Clear caches since credentials changed.
248        self::$credentials_cache = null;
249        self::clear_cached_token();
250
251        return update_option( self::CREDENTIALS_OPTION_KEY, $credentials, false );
252    }
253
254    /**
255     * Retrieve stored PayPal client credentials.
256     *
257     * Decrypts credentials from wp_options using sodium_crypto_secretbox.
258     * If decryption fails (corrupted data or AUTH_KEY changed), the stored
259     * credentials are deleted and false is returned.
260     *
261     * @return array|false Array with 'client_id' and 'client_secret' keys, or false if not set.
262     */
263    public static function get_credentials() {
264        // Return from request-scoped cache if available.
265        if ( null !== self::$credentials_cache ) {
266            return self::$credentials_cache;
267        }
268
269        $credentials = get_option( self::CREDENTIALS_OPTION_KEY, false );
270
271        if ( ! is_array( $credentials )
272            || empty( $credentials['encrypted_client_id'] )
273            || empty( $credentials['encrypted_client_secret'] )
274        ) {
275            self::$credentials_cache = false;
276            return false;
277        }
278
279        $client_id     = self::decrypt( $credentials['encrypted_client_id'] );
280        $client_secret = self::decrypt( $credentials['encrypted_client_secret'] );
281
282        if ( false === $client_id || false === $client_secret ) {
283            // Decryption failed — likely AUTH_KEY changed or data corrupted. Deleting
284            // is by design (WOOPTP-189); the log is the only trace it ever happened.
285            error_log( 'PayPal Payment Buttons: stored credentials could not be decrypted; deleting them. AUTH_KEY may have changed.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
286            self::delete_credentials();
287            return false;
288        }
289
290        self::$credentials_cache = array(
291            'client_id'     => $client_id,
292            'client_secret' => $client_secret,
293        );
294
295        return self::$credentials_cache;
296    }
297
298    /**
299     * Check whether PayPal credentials are stored and valid.
300     *
301     * @return bool True if credentials exist and pass integrity check.
302     */
303    public static function has_credentials() {
304        return false !== self::get_credentials();
305    }
306
307    /**
308     * Delete stored PayPal credentials and cached token.
309     *
310     * @return bool True on success, false on failure.
311     */
312    public static function delete_credentials() {
313        self::$credentials_cache = null;
314        self::clear_cached_token();
315        return delete_option( self::CREDENTIALS_OPTION_KEY );
316    }
317
318    /**
319     * Get a valid OAuth access token.
320     *
321     * Returns a cached token if still valid, otherwise requests a new one
322     * from PayPal's OAuth endpoint using the client credentials grant.
323     *
324     * @return string|\WP_Error The access token string, or WP_Error on failure.
325     */
326    public static function get_access_token() {
327        // Try cached token first (stored encrypted).
328        $cached_encrypted = get_transient( self::TOKEN_TRANSIENT_KEY );
329        if ( false !== $cached_encrypted && is_string( $cached_encrypted ) ) {
330            // Double-check absolute expiry timestamp in case the transient
331            // survived an object-cache flush or clock drift.
332            $expires_at = get_option( self::TOKEN_EXPIRES_AT_OPTION_KEY, 0 );
333            if ( $expires_at > 0 && time() >= $expires_at ) {
334                self::clear_cached_token();
335                return self::request_access_token_with_lock();
336            }
337
338            $cached_token = self::decrypt( $cached_encrypted );
339            if ( false === $cached_token ) {
340                // Decryption failed — request a fresh token.
341                self::clear_cached_token();
342                return self::request_access_token_with_lock();
343            }
344
345            return $cached_token;
346        }
347
348        // No valid cached token — request a new one.
349        return self::request_access_token_with_lock();
350    }
351
352    /**
353     * Request a new access token with a best-effort mutex to reduce concurrent refreshes.
354     *
355     * Uses wp_cache_add() which is atomic on persistent object caches (Redis, Memcached).
356     * On sites without a persistent cache, this is per-request only — duplicate refreshes
357     * may still occur, which is acceptable since the token endpoint is idempotent.
358     *
359     * @return string|\WP_Error The access token string, or WP_Error on failure.
360     */
361    private static function request_access_token_with_lock() {
362        $lock_key     = 'paypal_token_refresh_lock';
363        $lock_timeout = 30; // seconds.
364
365        // wp_cache_add returns false if key already exists (atomic on persistent caches).
366        $acquired = wp_cache_add( $lock_key, true, '', $lock_timeout );
367
368        if ( ! $acquired ) {
369            // Another process is refreshing. Wait briefly for the new token.
370            for ( $i = 0; $i < 4; $i++ ) {
371                usleep( 250000 ); // 0.25 seconds, max 1 second total.
372                $cached_encrypted = get_transient( self::TOKEN_TRANSIENT_KEY );
373                if ( false !== $cached_encrypted && is_string( $cached_encrypted ) ) {
374                    $token = self::decrypt( $cached_encrypted );
375                    if ( false !== $token ) {
376                        return $token;
377                    }
378                }
379            }
380            // Fallthrough: other process may have failed. Proceed with our own request.
381        }
382
383        $result = self::request_access_token();
384
385        wp_cache_delete( $lock_key );
386
387        return $result;
388    }
389
390    /**
391     * Request a new OAuth access token from PayPal.
392     *
393     * Uses the client credentials grant type with HTTP Basic authentication.
394     *
395     * @return string|\WP_Error The access token string, or WP_Error on failure.
396     */
397    private static function request_access_token() {
398        $credentials = self::get_credentials();
399        if ( false === $credentials ) {
400            return new \WP_Error(
401                'paypal_no_credentials',
402                __( 'PayPal API credentials are not configured. Please connect your PayPal account.', 'jetpack-paypal-payments' )
403            );
404        }
405
406        $url = self::get_base_url() . self::TOKEN_ENDPOINT;
407
408        $response = wp_remote_post(
409            $url,
410            array(
411                'timeout' => 30,
412                'headers' => array(
413                    'Authorization' => 'Basic ' . base64_encode( $credentials['client_id'] . ':' . $credentials['client_secret'] ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required by PayPal OAuth spec.
414                    'Content-Type'  => 'application/x-www-form-urlencoded',
415                    'Accept'        => 'application/json',
416                ),
417                'body'    => 'grant_type=client_credentials',
418            )
419        );
420
421        if ( is_wp_error( $response ) ) {
422            return new \WP_Error(
423                'paypal_token_request_failed',
424                sprintf(
425                    /* translators: %s: error message from the HTTP request */
426                    __( 'Failed to connect to PayPal: %s', 'jetpack-paypal-payments' ),
427                    $response->get_error_message()
428                )
429            );
430        }
431
432        $status_code = wp_remote_retrieve_response_code( $response );
433        $body        = wp_remote_retrieve_body( $response );
434        $data        = json_decode( $body, true );
435
436        if ( 200 !== $status_code ) {
437            $error_description = isset( $data['error_description'] )
438                ? sanitize_text_field( $data['error_description'] )
439                : __( 'Unknown error', 'jetpack-paypal-payments' );
440
441            $error_code = isset( $data['error'] )
442                ? sanitize_text_field( $data['error'] )
443                : 'paypal_token_error';
444
445            return new \WP_Error(
446                'paypal_token_' . $error_code,
447                sprintf(
448                    /* translators: 1: HTTP status code, 2: error description from PayPal */
449                    __( 'PayPal authentication failed (HTTP %1$d): %2$s', 'jetpack-paypal-payments' ),
450                    $status_code,
451                    $error_description
452                ),
453                array( 'status' => $status_code )
454            );
455        }
456
457        if ( empty( $data['access_token'] ) ) {
458            return new \WP_Error(
459                'paypal_token_missing',
460                __( 'PayPal returned a successful response but no access token was included.', 'jetpack-paypal-payments' )
461            );
462        }
463
464        // Use trim() to preserve valid OAuth token characters that sanitize_text_field() may strip.
465        $access_token = trim( $data['access_token'] );
466        $expires_in   = isset( $data['expires_in'] ) ? absint( $data['expires_in'] ) : 0;
467
468        // Cache the token encrypted with a buffer before expiry.
469        if ( $expires_in > self::TOKEN_EXPIRY_BUFFER ) {
470            $cache_duration  = $expires_in - self::TOKEN_EXPIRY_BUFFER;
471            $encrypted_token = self::encrypt( $access_token );
472            if ( ! is_wp_error( $encrypted_token ) ) {
473                set_transient( self::TOKEN_TRANSIENT_KEY, $encrypted_token, $cache_duration );
474            }
475
476            // Store absolute expiry timestamp as a fallback for object-cache eviction.
477            update_option( self::TOKEN_EXPIRES_AT_OPTION_KEY, time() + $cache_duration, false );
478        }
479
480        return $access_token;
481    }
482
483    /**
484     * Clear the cached OAuth access token.
485     *
486     * @return bool True if the transient was deleted, false otherwise.
487     */
488    public static function clear_cached_token() {
489        delete_option( self::TOKEN_EXPIRES_AT_OPTION_KEY );
490        return delete_transient( self::TOKEN_TRANSIENT_KEY );
491    }
492
493    /**
494     * Validate stored credentials by attempting a token exchange.
495     *
496     * Useful for verifying that the merchant's client_id and secret
497     * are correct and that Payment Links & Buttons is enabled.
498     *
499     * @return true|\WP_Error True if credentials are valid, WP_Error otherwise.
500     */
501    public static function validate_credentials() {
502        // Force a fresh token request (bypass cache).
503        self::clear_cached_token();
504
505        $token = self::request_access_token();
506
507        if ( is_wp_error( $token ) ) {
508            return $token;
509        }
510
511        return true;
512    }
513
514    /**
515     * Validate that the authenticated account has access to the Payment Links & Buttons API.
516     *
517     * Probes GET /v1/checkout/payment-resources?page_size=1 after a successful
518     * token exchange. A 403 means the merchant's app lacks the required scope.
519     * Transient server errors (5xx, timeouts) are treated as non-blocking so
520     * the connect flow is not disrupted by temporary PayPal outages.
521     *
522     * @return true|\WP_Error True if the account has API access, WP_Error on 403.
523     */
524    public static function validate_api_access() {
525        $token = self::get_access_token();
526        if ( is_wp_error( $token ) ) {
527            return $token;
528        }
529
530        $url = self::get_base_url() . '/v1/checkout/payment-resources?page_size=1';
531
532        $response = wp_remote_get(
533            $url,
534            array(
535                'timeout' => 15,
536                'headers' => array(
537                    'Authorization' => 'Bearer ' . $token,
538                    'Content-Type'  => 'application/json',
539                    'Accept'        => 'application/json',
540                ),
541            )
542        );
543
544        // Network-level failures are non-blocking.
545        if ( is_wp_error( $response ) ) {
546            return true;
547        }
548
549        $status_code = wp_remote_retrieve_response_code( $response );
550
551        // 5xx / unexpected codes — treat as transient, don't block connect.
552        if ( $status_code >= 500 || 0 === $status_code ) {
553            return true;
554        }
555
556        // 403 — the app lacks Payment Links & Buttons access.
557        if ( 403 === $status_code ) {
558            $message = __(
559                'Your PayPal app does not have access to Payment Links & Buttons. In the PayPal Developer Dashboard, open your app settings and enable the "Payment Links & Buttons" feature, then try connecting again.',
560                'jetpack-paypal-payments'
561            );
562
563            // PayPal's own diagnosis beats our guess; the debug_id is what their
564            // support and status tooling resolve.
565            $body     = json_decode( wp_remote_retrieve_body( $response ), true );
566            $name     = isset( $body['name'] ) ? sanitize_text_field( $body['name'] ) : '';
567            $debug_id = isset( $body['debug_id'] ) ? sanitize_text_field( $body['debug_id'] ) : '';
568            if ( $name || $debug_id ) {
569                $message .= ' ' . sprintf(
570                    /* translators: 1: PayPal's error name, 2: PayPal's debug ID. */
571                    __( 'PayPal reported: %1$s (debug ID %2$s).', 'jetpack-paypal-payments' ),
572                    $name ? $name : __( 'unknown error', 'jetpack-paypal-payments' ),
573                    $debug_id ? $debug_id : '—'
574                );
575            }
576
577            return new \WP_Error(
578                'paypal_api_not_authorized',
579                $message,
580                array( 'status' => 403 )
581            );
582        }
583
584        // 200, 204, or other success / client errors (400, 404) mean the API is reachable.
585        return true;
586    }
587
588    /**
589     * Get the connection status for display in the block editor.
590     *
591     * @return array {
592     *     Connection status information.
593     *
594     *     @type bool   $connected                   Whether credentials are stored.
595     *     @type string $environment                 Current environment ('sandbox' or 'production').
596     *     @type string $onboarding_method           How the merchant connected, when that is known.
597     *     @type string $merchant_id                 The merchant's PayPal ID, when that is known.
598     *     @type bool   $partner_referrals_available Whether this site can start onboarding through WordPress.com.
599     * }
600     */
601    public static function get_connection_status() {
602        $status = array(
603            'connected'   => self::has_credentials(),
604            'environment' => self::get_environment(),
605        );
606
607        // Include onboarding method if connected via Partner Referrals.
608        $method = get_option( PayPal_Partner_Onboarding::ONBOARDING_METHOD_OPTION_KEY, '' );
609        if ( ! empty( $method ) ) {
610            $status['onboarding_method'] = $method;
611        }
612
613        $merchant_id = get_option( PayPal_Partner_Onboarding::MERCHANT_ID_OPTION_KEY, '' );
614        if ( ! empty( $merchant_id ) ) {
615            $status['merchant_id'] = $merchant_id;
616        }
617
618        // Partner Referrals goes through WordPress.com: on WordPress.com the call is
619        // local, anywhere else it needs a Jetpack connection to authenticate it.
620        $status['partner_referrals_available'] = ( new Host() )->is_wpcom_simple()
621            || ( new Manager() )->is_connected();
622
623        return $status;
624    }
625
626    /**
627     * Clean up all PayPal OAuth data.
628     *
629     * Removes stored credentials, cached token, and environment setting.
630     * Used during plugin deactivation or full disconnect.
631     *
632     * @return void
633     */
634    public static function disconnect() {
635        self::$credentials_cache = null;
636        delete_option( self::CREDENTIALS_OPTION_KEY );
637        delete_option( self::ENVIRONMENT_OPTION_KEY );
638        delete_option( self::TOKEN_EXPIRES_AT_OPTION_KEY );
639        self::clear_cached_token();
640    }
641}