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