Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.61% covered (success)
98.61%
497 / 504
92.86% covered (success)
92.86%
26 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding
99.20% covered (success)
99.20%
497 / 501
92.86% covered (success)
92.86%
26 / 28
125
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
104 / 104
100.00% covered (success)
100.00%
1 / 1
2
 permission_check
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 site_id
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
11.07
 validate_return_url
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 sanitize_partner_attribution_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 validate_path
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 generate_signup_link
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
12
 build_referral
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 get_merchant_integration_status
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
12
 forward_request
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
5
 build_auth_assertion
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 tracking_id_belongs_to_site
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 merchant_not_for_site_error
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 assert_merchant_belongs_to_site
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 tracking_id_names_merchant
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 remember_merchant_binding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 merchant_binding_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 find_merchant_id_by_tracking_id
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 get_merchant_integration
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 decode_body
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 paypal_error_data
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 paypal_request
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
8
 log_paypal_error
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
5.20
 base_url
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_platform_credentials
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
12
 token_cache_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_paypal_access_token
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2/**
3 * WPCOM REST API v2 endpoints for the PayPal Payment Buttons platform integration.
4 *
5 * Automattic's PayPal platform credentials (client_id/client_secret) stay
6 * server-side and never ship in the plugin. The site creates its Partner
7 * Referral here, looks up the seller it referred here and, because a
8 * THIRD_PARTY seller holds no credentials of their own, makes every Payment
9 * Links & Buttons call here as well.
10 *
11 * Plugin side: PayPal_Partner_Onboarding and PayPal_Platform_Client call these
12 * routes via Client::wpcom_json_api_request_as_blog().
13 *
14 * @package automattic/jetpack-mu-wpcom
15 * @since $$next-version$$
16 * @see https://developer.paypal.com/docs/multiparty/seller-onboarding/build-onboarding/
17 */
18
19use Automattic\Jetpack\Constants;
20use Automattic\Jetpack\Feature_Flags\Feature_Flags;
21
22if ( ! defined( 'ABSPATH' ) ) {
23    exit( 0 );
24}
25
26/**
27 * PayPal platform: Partner Referrals signup links, merchant lookups, and
28 * Payment Links & Buttons calls made on a referred seller's behalf.
29 *
30 * @since $$next-version$$
31 */
32class WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding extends WP_REST_Controller {
33
34    /**
35     * PayPal production API base URL.
36     *
37     * @var string
38     */
39    const PAYPAL_PRODUCTION_BASE_URL = 'https://api.paypal.com';
40
41    /**
42     * PayPal sandbox API base URL.
43     *
44     * @var string
45     */
46    const PAYPAL_SANDBOX_BASE_URL = 'https://api-m.sandbox.paypal.com';
47
48    /**
49     * PayPal Partner Referrals API endpoint.
50     *
51     * @var string
52     */
53    const PAYPAL_REFERRALS_ENDPOINT = '/v2/customer/partner-referrals';
54
55    /**
56     * PayPal OAuth token endpoint.
57     *
58     * @var string
59     */
60    const PAYPAL_TOKEN_ENDPOINT = '/v1/oauth2/token';
61
62    /**
63     * Merchant integrations endpoint template. Replace {partner_id} at call time.
64     *
65     * @var string
66     */
67    const PAYPAL_MERCHANT_INTEGRATIONS_ENDPOINT = '/v1/customer/partners/%s/merchant-integrations';
68
69    /**
70     * The one PayPal API the site may call on a seller's behalf.
71     *
72     * @var string
73     */
74    const PAYPAL_PAYMENT_RESOURCES_ENDPOINT = '/v1/checkout/payment-resources';
75
76    /**
77     * Products to request during onboarding.
78     *
79     * @var array
80     */
81    const ONBOARDING_PRODUCTS = array( 'EXPRESS_CHECKOUT' );
82
83    /**
84     * Permissions the seller grants the platform during onboarding.
85     *
86     * PAYMENT_LINKS_AND_BUTTONS is the one that covers /v1/checkout/payment-resources,
87     * the endpoint every button is created through. Neither EXPRESS_CHECKOUT
88     * nor PPCP includes it, and PayPal does not document the valid feature
89     * values, so it has to be requested by name.
90     *
91     * @var array
92     */
93    const ONBOARDING_FEATURES = array( 'PAYMENT', 'REFUND', 'ACCESS_MERCHANT_INFORMATION', 'PAYMENT_LINKS_AND_BUTTONS' );
94
95    /**
96     * Prefix of every tracking ID this endpoint issues; the blog ID follows it.
97     *
98     * @var string
99     */
100    const TRACKING_ID_PREFIX = 'woo-ncps-';
101
102    /**
103     * How long a verified blog-to-merchant binding is remembered, in seconds.
104     *
105     * @var int
106     */
107    const MERCHANT_BINDING_TTL = 12 * HOUR_IN_SECONDS;
108
109    /**
110     * Seconds taken off a platform token's lifetime before it is refreshed.
111     *
112     * @var int
113     */
114    const TOKEN_EXPIRY_BUFFER = 300;
115
116    /**
117     * Names of the constants holding Automattic's PayPal platform credentials, by environment.
118     *
119     * The values themselves live in WordPress.com's secrets configuration. Only the
120     * constant *names* are stored here: referencing an undefined constant inside a
121     * constant expression is a fatal error, and these are never defined on self-hosted
122     * sites, where onboarding is proxied to WordPress.com instead. Read them through
123     * get_platform_credentials(), which tolerates their absence.
124     *
125     * @var array<string, array<string, string>>
126     */
127    const PLATFORM_CREDENTIAL_CONSTANTS = array(
128        'production' => array(
129            'client_id'           => 'PAYPAL_BUTTONS_PRODUCTION_CLIENT_ID',
130            'client_secret'       => 'PAYPAL_BUTTONS_PRODUCTION_CLIENT_SECRET',
131            'partner_merchant_id' => 'PAYPAL_BUTTONS_PRODUCTION_PARTNER_MERCHANT_ID',
132        ),
133        'sandbox'    => array(
134            'client_id'           => 'PAYPAL_BUTTONS_SANDBOX_CLIENT_ID',
135            'client_secret'       => 'PAYPAL_BUTTONS_SANDBOX_CLIENT_SECRET',
136            'partner_merchant_id' => 'PAYPAL_BUTTONS_SANDBOX_PARTNER_MERCHANT_ID',
137        ),
138    );
139
140    /**
141     * Constructor.
142     */
143    public function __construct() {
144        $this->namespace = 'wpcom/v2';
145
146        /*
147         * 'paypal/platform', not 'paypal/onboarding': the package registers the
148         * editor-facing wpcom/v2/paypal/onboarding/signup-link on every host that
149         * runs it, including this one. Sharing the path would mean two classes
150         * claiming one route, and the site proxying to itself.
151         */
152        $this->rest_base = 'paypal/platform';
153
154        /*
155         * Opt out of WordPress.com's centralize.php rewrite, which otherwise moves every
156         * wpcom/v2 route to /wpcom/v2/sites/<site>/... . Client::wpcom_json_api_request_as_blog()
157         * builds a flat /wpcom/v2/<path> URL -- the blog ID travels as a signed argument, not
158         * in the path -- so a rewritten route is unreachable from it and every call came back
159         * rest_no_route.
160         *
161         * A flat route runs on public-api's own blog, so the current blog never names the
162         * caller: site_id() reads it from the signed blog token instead.
163         *
164         * The wpcom-only flag stops public-api's proxy_jetpack() from forwarding the call
165         * to a Jetpack site and answering rest_not_implemented, which is right here: the
166         * credentials are Automattic's and live on WordPress.com servers. A false
167         * site_specific already implies wpcom-only, but both are set explicitly -- as
168         * WPCOM_REST_API_V2_Endpoint_Following does -- so neither relies on the other's
169         * side effect.
170         */
171        $this->wpcom_is_wpcom_only_endpoint    = true;
172        $this->wpcom_is_site_specific_endpoint = false;
173
174        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
175    }
176
177    /**
178     * Register REST API routes.
179     */
180    public function register_routes() {
181        // Hard-coded: mu-wpcom cannot reach PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG.
182        // Unregistered here, so only a `jetpack_feature_flag_enabled_*` filter flips it on wpcom.
183        if ( ! Feature_Flags::is_enabled( 'paypal-payments-api-managed-buttons' ) ) {
184            return;
185        }
186
187        $environment_arg = array(
188            'required'          => true,
189            'type'              => 'string',
190            'enum'              => array( 'sandbox', 'production' ),
191            'sanitize_callback' => 'sanitize_text_field',
192            'description'       => 'PayPal environment: sandbox or production.',
193        );
194
195        $merchant_id_arg = array(
196            'type'              => 'string',
197            'sanitize_callback' => 'sanitize_text_field',
198            'description'       => 'The seller\'s PayPal merchant ID.',
199        );
200
201        register_rest_route(
202            $this->namespace,
203            $this->rest_base . '/signup-link',
204            array(
205                array(
206                    'methods'             => WP_REST_Server::CREATABLE,
207                    'callback'            => array( $this, 'generate_signup_link' ),
208                    'permission_callback' => array( $this, 'permission_check' ),
209                    'args'                => array(
210                        'environment'            => $environment_arg,
211                        'return_url'             => array(
212                            'required'          => true,
213                            'type'              => 'string',
214                            'validate_callback' => array( $this, 'validate_return_url' ),
215                            'sanitize_callback' => 'esc_url_raw',
216                            'description'       => 'The site page PayPal sends the seller back to.',
217                        ),
218                        'partner_attribution_id' => array(
219                            'type'              => 'string',
220                            'sanitize_callback' => array( $this, 'sanitize_partner_attribution_id' ),
221                            'description'       => 'The partner attribution (BN) code the site resolved for its environment.',
222                        ),
223                    ),
224                ),
225            )
226        );
227
228        register_rest_route(
229            $this->namespace,
230            $this->rest_base . '/merchant-integration',
231            array(
232                array(
233                    'methods'             => WP_REST_Server::READABLE,
234                    'callback'            => array( $this, 'get_merchant_integration_status' ),
235                    'permission_callback' => array( $this, 'permission_check' ),
236                    'args'                => array(
237                        'environment' => $environment_arg,
238                        'merchant_id' => $merchant_id_arg,
239                        'tracking_id' => array(
240                            'type'              => 'string',
241                            'sanitize_callback' => 'sanitize_text_field',
242                            'description'       => 'The tracking ID the referral was created with: to find a seller who just finished onboarding, or with a merchant ID, as proof the blog referred them.',
243                        ),
244                    ),
245                ),
246            )
247        );
248
249        register_rest_route(
250            $this->namespace,
251            $this->rest_base . '/request',
252            array(
253                array(
254                    'methods'             => WP_REST_Server::CREATABLE,
255                    'callback'            => array( $this, 'forward_request' ),
256                    'permission_callback' => array( $this, 'permission_check' ),
257                    'args'                => array(
258                        'environment' => $environment_arg,
259                        'merchant_id' => array_merge( $merchant_id_arg, array( 'required' => true ) ),
260                        'tracking_id' => array(
261                            'type'              => 'string',
262                            'default'           => '',
263                            'sanitize_callback' => 'sanitize_text_field',
264                            'description'       => 'The tracking ID the blog onboarded the seller with, as proof it referred them.',
265                        ),
266                        'method'      => array(
267                            'required'    => true,
268                            'type'        => 'string',
269                            'enum'        => array( 'GET', 'POST', 'PUT', 'DELETE' ),
270                            'description' => 'HTTP method of the PayPal call.',
271                        ),
272                        'path'        => array(
273                            'required'          => true,
274                            'type'              => 'string',
275                            'validate_callback' => array( $this, 'validate_path' ),
276                            'description'       => 'PayPal API path, with any query string. Payment Links & Buttons only.',
277                        ),
278                        'body'        => array(
279                            'type'        => array( 'object', 'null' ),
280                            'default'     => null,
281                            'description' => 'JSON body for POST and PUT.',
282                        ),
283                        'request_id'  => array(
284                            'type'              => 'string',
285                            'default'           => '',
286                            'sanitize_callback' => 'sanitize_text_field',
287                            'description'       => 'PayPal-Request-Id idempotency key.',
288                        ),
289                    ),
290                ),
291            )
292        );
293    }
294
295    /**
296     * Permission check â€” only a site that can be identified may call.
297     *
298     * @return true|WP_Error
299     */
300    public function permission_check() {
301        if ( 0 === $this->site_id() ) {
302            return new WP_Error(
303                'not_connected',
304                __( 'Site is not connected to WordPress.com.', 'jetpack-mu-wpcom' ),
305                array( 'status' => 403 )
306            );
307        }
308        return true;
309    }
310
311    /**
312     * The calling blog's ID, or 0 when the request cannot be tied to one.
313     *
314     * The routes are flat, so the current blog is public-api's own for every HTTP
315     * caller; the Jetpack blog token the request is signed with names the site.
316     *
317     * @return int
318     */
319    private function site_id() {
320        // Client::wpcom_json_api_request_as_blog() sets this on a Simple site's
321        // in-process call, which WPCOM_API_Direct runs switched to that site's blog.
322        if ( Constants::is_true( 'IS_WPCOM' ) && true === apply_filters( 'is_jetpack_authorized_for_site', false ) ) {
323            return get_current_blog_id();
324        }
325
326        if ( ! class_exists( 'Jetpack_Server_Version' ) ) {
327            return 0;
328        }
329
330        // Checks the request signature against the token it names.
331        $token = Jetpack_Server_Version::get_token_from_authorization_header();
332        if ( ! is_object( $token ) || is_wp_error( $token ) || empty( $token->blog_id ) ) {
333            return 0;
334        }
335
336        // A user token would let any of the site's users act as the site.
337        if ( ! empty( $token->user_id ) || ! empty( $token->external_user_id ) ) {
338            return 0;
339        }
340
341        if ( function_exists( 'is_suspended' ) && is_suspended( $token->blog_id ) ) {
342            return 0;
343        }
344
345        return (int) $token->blog_id;
346    }
347
348    /**
349     * Only a web page may be the return URL, and only over HTTPS in production.
350     *
351     * @param mixed           $value   The return_url parameter.
352     * @param WP_REST_Request $request The REST request.
353     * @return bool
354     */
355    public function validate_return_url( $value, $request ) {
356        if ( ! is_string( $value ) ) {
357            return false;
358        }
359
360        $scheme = wp_parse_url( $value, PHP_URL_SCHEME );
361        $host   = wp_parse_url( $value, PHP_URL_HOST );
362        if ( ! is_string( $host ) || '' === $host ) {
363            return false;
364        }
365
366        return 'production' === $request->get_param( 'environment' )
367            ? 'https' === $scheme
368            : in_array( $scheme, array( 'http', 'https' ), true );
369    }
370
371    /**
372     * BN codes are alphanumeric with underscores and hyphens; anything else is dropped.
373     *
374     * @param mixed $value The partner_attribution_id parameter.
375     * @return string
376     */
377    public function sanitize_partner_attribution_id( $value ) {
378        return is_string( $value ) ? preg_replace( '/[^A-Za-z0-9_-]/', '', $value ) : '';
379    }
380
381    /**
382     * Only the Payment Links & Buttons API may be called on a seller's behalf.
383     *
384     * @param mixed $value The path parameter.
385     * @return bool
386     */
387    public function validate_path( $value ) {
388        return is_string( $value )
389            && 1 === preg_match( '#^' . preg_quote( self::PAYPAL_PAYMENT_RESOURCES_ENDPOINT, '#' ) . '(/[A-Za-z0-9-]+)?(\?[^\s]*)?\z#', $value );
390    }
391
392    /**
393     * Generate a PayPal Partner Referrals signup link.
394     *
395     * Authenticates with PayPal using Automattic's platform credentials,
396     * creates a partner referral, and returns the action_url. The referral is
397     * built here, so a site chooses only where PayPal sends the seller back.
398     *
399     * @param WP_REST_Request $request The REST request.
400     * @return WP_REST_Response|WP_Error
401     */
402    public function generate_signup_link( WP_REST_Request $request ) {
403        $environment = $request->get_param( 'environment' );
404
405        $credentials = $this->get_platform_credentials( $environment );
406        if ( is_wp_error( $credentials ) ) {
407            return $credentials;
408        }
409
410        // The tracking ID is what later ties the seller back to this blog.
411        $tracking_id = self::TRACKING_ID_PREFIX . $this->site_id() . '-' . time();
412
413        // The site resolves the BN code, since only it knows whether a sandbox override applies.
414        $headers                = array();
415        $partner_attribution_id = (string) $request->get_param( 'partner_attribution_id' );
416        if ( '' !== $partner_attribution_id ) {
417            $headers['PayPal-Partner-Attribution-Id'] = $partner_attribution_id;
418        }
419
420        $response = $this->paypal_request(
421            $environment,
422            $credentials,
423            'POST',
424            self::PAYPAL_REFERRALS_ENDPOINT,
425            self::build_referral( $tracking_id, (string) $request->get_param( 'return_url' ) ),
426            $headers
427        );
428
429        if ( is_wp_error( $response ) ) {
430            return $response;
431        }
432
433        $status_code = wp_remote_retrieve_response_code( $response );
434        $body        = self::decode_body( $response );
435
436        if ( 201 !== $status_code && 200 !== $status_code ) {
437            /*
438             * PayPal's top-level message for a rejected referral is always the same
439             * generic sentence ("Request is not well-formed, syntactically
440             * incorrect, or violates schema."). Everything needed to act on it is
441             * in `details`, which names the offending field and issue, and in
442             * `debug_id`, which PayPal support needs to trace the call. Passing
443             * only the message through left callers with nothing to go on, so
444             * carry both. None of it is credential material.
445             */
446            return new WP_Error(
447                'paypal_referral_failed',
448                $body['message'] ?? 'PayPal Partner Referrals API returned an error.',
449                $this->paypal_error_data( $status_code, $body )
450            );
451        }
452
453        $action_url  = '';
454        $referral_id = '';
455
456        if ( isset( $body['links'] ) && is_array( $body['links'] ) ) {
457            foreach ( $body['links'] as $link ) {
458                if ( 'action_url' === $link['rel'] ) {
459                    $action_url = $link['href'];
460                }
461                if ( 'self' === $link['rel'] ) {
462                    $parts       = explode( '/', $link['href'] );
463                    $referral_id = end( $parts );
464                }
465            }
466        }
467
468        if ( empty( $action_url ) ) {
469            return new WP_Error(
470                'paypal_no_action_url',
471                'PayPal returned a successful response but no onboarding URL was included.',
472                array( 'status' => 502 )
473            );
474        }
475
476        return rest_ensure_response(
477            array(
478                'action_url'        => $action_url,
479                'referral_id'       => $referral_id,
480                'tracking_id'       => $tracking_id,
481                // Public: the site puts it in the JS SDK URL, next to the seller's merchant ID.
482                'partner_client_id' => $credentials['client_id'],
483            )
484        );
485    }
486
487    /**
488     * The Partner Referrals body for a THIRD_PARTY seller.
489     *
490     * @param string $tracking_id The tracking ID issued for the calling blog.
491     * @param string $return_url  Where PayPal sends the seller back.
492     * @return array
493     */
494    public static function build_referral( $tracking_id, $return_url ) {
495        return array(
496            'tracking_id'             => $tracking_id,
497            'partner_config_override' => array(
498                'return_url'             => $return_url,
499                'return_url_description' => __( 'Return to your WordPress site to complete setup.', 'jetpack-mu-wpcom' ),
500                'show_add_credit_card'   => true,
501            ),
502            'operations'              => array(
503                array(
504                    'operation'                  => 'API_INTEGRATION',
505                    'api_integration_preference' => array(
506                        'rest_api_integration' => array(
507                            'integration_method'  => 'PAYPAL',
508                            'integration_type'    => 'THIRD_PARTY',
509                            'third_party_details' => array(
510                                'features' => self::ONBOARDING_FEATURES,
511                            ),
512                        ),
513                    ),
514                ),
515            ),
516            'products'                => self::ONBOARDING_PRODUCTS,
517            'legal_consents'          => array(
518                array(
519                    'type'    => 'SHARE_DATA_CONSENT',
520                    'granted' => true,
521                ),
522            ),
523        );
524    }
525
526    /**
527     * Report a referred seller's integration status.
528     *
529     * By tracking ID for a seller who just finished onboarding, since PayPal's
530     * THIRD_PARTY flow hands the site nothing else to identify them by; by
531     * merchant ID afterwards.
532     *
533     * @param WP_REST_Request $request The REST request.
534     * @return WP_REST_Response|WP_Error PayPal's merchant integration, or WP_Error.
535     */
536    public function get_merchant_integration_status( WP_REST_Request $request ) {
537        $environment = $request->get_param( 'environment' );
538        $merchant_id = (string) $request->get_param( 'merchant_id' );
539        $tracking_id = (string) $request->get_param( 'tracking_id' );
540        $site_id     = $this->site_id();
541
542        $credentials = $this->get_platform_credentials( $environment );
543        if ( is_wp_error( $credentials ) ) {
544            return $credentials;
545        }
546
547        if ( '' === $merchant_id && '' === $tracking_id ) {
548            return new WP_Error(
549                'paypal_merchant_unspecified',
550                'A merchant ID or a tracking ID is required.',
551                array( 'status' => 400 )
552            );
553        }
554
555        if ( '' === $merchant_id ) {
556            // A tracking ID issued for another blog names a seller this site never referred.
557            if ( ! $this->tracking_id_belongs_to_site( $tracking_id, $site_id ) ) {
558                return $this->merchant_not_for_site_error();
559            }
560
561            $merchant_id = $this->find_merchant_id_by_tracking_id( $environment, $credentials, $tracking_id );
562            if ( is_wp_error( $merchant_id ) ) {
563                return $merchant_id;
564            }
565        } elseif ( '' !== $tracking_id ) {
566            $bound = $this->assert_merchant_belongs_to_site( $environment, $credentials, $merchant_id, $site_id, $tracking_id );
567            if ( is_wp_error( $bound ) ) {
568                return $bound;
569            }
570        }
571
572        $integration = $this->get_merchant_integration( $environment, $credentials, $merchant_id );
573        if ( is_wp_error( $integration ) ) {
574            return $integration;
575        }
576
577        // With no tracking ID of its own, a blog proves itself by PayPal's latest record.
578        if ( '' === $tracking_id && ! $this->tracking_id_belongs_to_site( $integration['tracking_id'] ?? '', $site_id ) ) {
579            return $this->merchant_not_for_site_error();
580        }
581
582        $this->remember_merchant_binding( $environment, $site_id, $merchant_id );
583
584        return rest_ensure_response( $integration );
585    }
586
587    /**
588     * Make one Payment Links & Buttons call on a referred seller's behalf.
589     *
590     * The call is signed with Automattic's platform token and a PayPal-Auth-Assertion
591     * naming the seller. PayPal's status and body come back as they are: the site
592     * already knows how to read them, and its own error messages depend on them.
593     *
594     * @param WP_REST_Request $request The REST request.
595     * @return WP_REST_Response|WP_Error {status, body} on any PayPal answer, WP_Error when PayPal could not be reached.
596     */
597    public function forward_request( WP_REST_Request $request ) {
598        $environment = $request->get_param( 'environment' );
599        $merchant_id = (string) $request->get_param( 'merchant_id' );
600        $site_id     = $this->site_id();
601
602        $credentials = $this->get_platform_credentials( $environment );
603        if ( is_wp_error( $credentials ) ) {
604            return $credentials;
605        }
606
607        $bound = $this->assert_merchant_belongs_to_site(
608            $environment,
609            $credentials,
610            $merchant_id,
611            $site_id,
612            (string) $request->get_param( 'tracking_id' )
613        );
614        if ( is_wp_error( $bound ) ) {
615            return $bound;
616        }
617
618        $headers = array(
619            'PayPal-Auth-Assertion' => self::build_auth_assertion( $credentials['client_id'], $merchant_id ),
620        );
621
622        $request_id = (string) $request->get_param( 'request_id' );
623        if ( '' !== $request_id ) {
624            $headers['PayPal-Request-Id'] = $request_id;
625        }
626
627        $response = $this->paypal_request(
628            $environment,
629            $credentials,
630            $request->get_param( 'method' ),
631            $request->get_param( 'path' ),
632            $request->get_param( 'body' ),
633            $headers
634        );
635
636        if ( is_wp_error( $response ) ) {
637            return $response;
638        }
639
640        return rest_ensure_response(
641            array(
642                'status' => (int) wp_remote_retrieve_response_code( $response ),
643                'body'   => (string) wp_remote_retrieve_body( $response ),
644            )
645        );
646    }
647
648    /**
649     * The PayPal-Auth-Assertion header value for acting on a seller's behalf.
650     *
651     * An unsigned JWT ("alg": "none") naming the partner's client ID as issuer and
652     * the seller as payer, per PayPal's third-party integration contract.
653     *
654     * @param string $client_id   The platform client ID.
655     * @param string $merchant_id The seller's PayPal merchant ID.
656     * @return string
657     */
658    public static function build_auth_assertion( $client_id, $merchant_id ) {
659        // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- JWT segments, as PayPal specifies.
660        $header  = base64_encode( wp_json_encode( array( 'alg' => 'none' ), JSON_UNESCAPED_SLASHES ) );
661        $payload = base64_encode(
662            wp_json_encode(
663                array(
664                    'iss'      => $client_id,
665                    'payer_id' => $merchant_id,
666                ),
667                JSON_UNESCAPED_SLASHES
668            )
669        );
670        // phpcs:enable
671
672        return $header . '.' . $payload . '.';
673    }
674
675    /**
676     * Whether a tracking ID was issued for this blog.
677     *
678     * @param string $tracking_id A referral tracking ID.
679     * @param int    $site_id     The calling blog's ID.
680     * @return bool
681     */
682    private function tracking_id_belongs_to_site( $tracking_id, $site_id ) {
683        return 0 === strpos( (string) $tracking_id, self::TRACKING_ID_PREFIX . $site_id . '-' );
684    }
685
686    /**
687     * The error for a merchant this blog did not refer.
688     *
689     * @return WP_Error
690     */
691    private function merchant_not_for_site_error() {
692        return new WP_Error(
693            'paypal_merchant_not_for_site',
694            'This PayPal account was not connected through this site.',
695            array( 'status' => 403 )
696        );
697    }
698
699    /**
700     * Refuse to act for a seller this blog did not refer.
701     *
702     * The blog token proves which site is calling, not which seller it may act
703     * for. A blog that sends the tracking ID it onboarded the seller with is
704     * checked by resolving that ID at PayPal; one that sends none is checked
705     * against the seller's integration record, which names only the latest
706     * referral. A verified pair is remembered so the check is not a PayPal call
707     * on every button operation.
708     *
709     * @param string $environment 'sandbox' or 'production'.
710     * @param array  $credentials Platform credentials.
711     * @param string $merchant_id The seller's PayPal merchant ID.
712     * @param int    $site_id     The calling blog's ID.
713     * @param string $tracking_id The tracking ID the blog onboarded the seller with, when it has one.
714     * @return true|WP_Error
715     */
716    private function assert_merchant_belongs_to_site( $environment, $credentials, $merchant_id, $site_id, $tracking_id = '' ) {
717        if ( '' === $merchant_id ) {
718            return $this->merchant_not_for_site_error();
719        }
720
721        if ( get_transient( $this->merchant_binding_key( $environment, $site_id, $merchant_id ) ) ) {
722            return true;
723        }
724
725        if ( '' !== $tracking_id ) {
726            $named = $this->tracking_id_names_merchant( $environment, $credentials, $tracking_id, $merchant_id, $site_id );
727            if ( is_wp_error( $named ) ) {
728                return $named;
729            }
730        } else {
731            $integration = $this->get_merchant_integration( $environment, $credentials, $merchant_id );
732            if ( is_wp_error( $integration ) ) {
733                return $integration;
734            }
735
736            if ( ! $this->tracking_id_belongs_to_site( $integration['tracking_id'] ?? '', $site_id ) ) {
737                return $this->merchant_not_for_site_error();
738            }
739        }
740
741        $this->remember_merchant_binding( $environment, $site_id, $merchant_id );
742
743        return true;
744    }
745
746    /**
747     * Whether a blog's own tracking ID resolves to the seller at PayPal.
748     *
749     * PayPal keeps every tracking ID a seller ever onboarded with, so a blog's ID
750     * stays valid after the seller connects another site, which only moves the
751     * record's latest ID. Only WordPress.com mints these, for the blog named in
752     * them, so one that resolves proves the referral.
753     *
754     * @param string $environment 'sandbox' or 'production'.
755     * @param array  $credentials Platform credentials.
756     * @param string $tracking_id The tracking ID the blog presents.
757     * @param string $merchant_id The seller the blog wants to act for.
758     * @param int    $site_id     The calling blog's ID.
759     * @return true|WP_Error
760     */
761    private function tracking_id_names_merchant( $environment, $credentials, $tracking_id, $merchant_id, $site_id ) {
762        if ( ! $this->tracking_id_belongs_to_site( $tracking_id, $site_id ) ) {
763            return $this->merchant_not_for_site_error();
764        }
765
766        $found = $this->find_merchant_id_by_tracking_id( $environment, $credentials, $tracking_id );
767        if ( is_wp_error( $found ) ) {
768            // No such referral at PayPal: the blog never onboarded a seller with it.
769            return 'paypal_merchant_not_found' === $found->get_error_code()
770                ? $this->merchant_not_for_site_error()
771                : $found;
772        }
773
774        if ( $found !== $merchant_id ) {
775            return $this->merchant_not_for_site_error();
776        }
777
778        return true;
779    }
780
781    /**
782     * Remember that a seller was referred by a blog.
783     *
784     * @param string $environment 'sandbox' or 'production'.
785     * @param int    $site_id     The blog's ID.
786     * @param string $merchant_id The seller's PayPal merchant ID.
787     */
788    private function remember_merchant_binding( $environment, $site_id, $merchant_id ) {
789        set_transient( $this->merchant_binding_key( $environment, $site_id, $merchant_id ), 1, self::MERCHANT_BINDING_TTL );
790    }
791
792    /**
793     * Transient key for one blog-to-merchant binding.
794     *
795     * @param string $environment 'sandbox' or 'production'.
796     * @param int    $site_id     The blog's ID.
797     * @param string $merchant_id The seller's PayPal merchant ID.
798     * @return string
799     */
800    private function merchant_binding_key( $environment, $site_id, $merchant_id ) {
801        return 'paypal_platform_merchant_' . md5( $environment . '|' . $site_id . '|' . $merchant_id );
802    }
803
804    /**
805     * Find the merchant ID behind a tracking ID.
806     *
807     * @param string $environment 'sandbox' or 'production'.
808     * @param array  $credentials Platform credentials.
809     * @param string $tracking_id The referral's tracking ID.
810     * @return string|WP_Error
811     */
812    private function find_merchant_id_by_tracking_id( $environment, $credentials, $tracking_id ) {
813        $response = $this->paypal_request(
814            $environment,
815            $credentials,
816            'GET',
817            sprintf( self::PAYPAL_MERCHANT_INTEGRATIONS_ENDPOINT, $credentials['partner_merchant_id'] ) . '?tracking_id=' . rawurlencode( $tracking_id ),
818            null,
819            array(),
820            // Skip logging the 404 PayPal answers until the seller finishes onboarding.
821            array( 404 )
822        );
823
824        if ( is_wp_error( $response ) ) {
825            return $response;
826        }
827
828        $status_code = wp_remote_retrieve_response_code( $response );
829        $body        = self::decode_body( $response );
830
831        if ( 200 !== $status_code || empty( $body['merchant_id'] ) ) {
832            // PayPal answers 404 until the seller has finished, so the caller can retry.
833            return new WP_Error(
834                'paypal_merchant_not_found',
835                'PayPal has no seller for this onboarding session yet.',
836                $this->paypal_error_data( 200 === $status_code ? 404 : $status_code, $body )
837            );
838        }
839
840        return (string) $body['merchant_id'];
841    }
842
843    /**
844     * Read a seller's integration record.
845     *
846     * @param string $environment 'sandbox' or 'production'.
847     * @param array  $credentials Platform credentials.
848     * @param string $merchant_id The seller's PayPal merchant ID.
849     * @return array|WP_Error PayPal's merchant integration body.
850     */
851    private function get_merchant_integration( $environment, $credentials, $merchant_id ) {
852        $response = $this->paypal_request(
853            $environment,
854            $credentials,
855            'GET',
856            sprintf( self::PAYPAL_MERCHANT_INTEGRATIONS_ENDPOINT, $credentials['partner_merchant_id'] ) . '/' . rawurlencode( $merchant_id )
857        );
858
859        if ( is_wp_error( $response ) ) {
860            return $response;
861        }
862
863        $status_code = wp_remote_retrieve_response_code( $response );
864        $body        = self::decode_body( $response );
865
866        if ( 200 !== $status_code || array() === $body ) {
867            return new WP_Error(
868                'paypal_merchant_status_error',
869                'Could not retrieve merchant integration status from PayPal.',
870                $this->paypal_error_data( $status_code, $body )
871            );
872        }
873
874        return $body;
875    }
876
877    /**
878     * PayPal's JSON body as an array; anything that is not a JSON object reads as empty.
879     *
880     * @param array $response The wp_remote_request() response.
881     * @return array
882     */
883    private static function decode_body( $response ) {
884        $body = json_decode( wp_remote_retrieve_body( $response ), true );
885
886        return is_array( $body ) ? $body : array();
887    }
888
889    /**
890     * Error data carrying PayPal's diagnostics through to the site.
891     *
892     * @param int   $status_code PayPal's HTTP status.
893     * @param array $body        PayPal's decoded error body.
894     * @return array
895     */
896    private function paypal_error_data( $status_code, array $body ) {
897        $error_data = array( 'status' => $status_code );
898
899        if ( ! empty( $body['name'] ) ) {
900            $error_data['paypal_error'] = $body['name'];
901        }
902        if ( ! empty( $body['details'] ) && is_array( $body['details'] ) ) {
903            $error_data['paypal_details'] = $body['details'];
904        }
905        if ( ! empty( $body['debug_id'] ) ) {
906            $error_data['paypal_debug_id'] = $body['debug_id'];
907        }
908
909        return $error_data;
910    }
911
912    /**
913     * Make one authenticated call to PayPal with the platform token.
914     *
915     * @param string     $environment       'sandbox' or 'production'.
916     * @param array      $credentials       Platform credentials.
917     * @param string     $method            HTTP method.
918     * @param string     $path              API path, with any query string.
919     * @param array|null $body              JSON body for POST and PUT.
920     * @param array      $extra_headers     Headers added to the standard set.
921     * @param int[]      $unlogged_statuses Expected error statuses to skip logging.
922     * @return array|WP_Error The wp_remote_request() response, or WP_Error (502) when PayPal was unreachable.
923     */
924    private function paypal_request( $environment, $credentials, $method, $path, $body = null, $extra_headers = array(), $unlogged_statuses = array() ) {
925        $base_url = $this->base_url( $environment );
926
927        $token = $this->get_paypal_access_token( $environment, $base_url, $credentials['client_id'], $credentials['client_secret'] );
928        if ( is_wp_error( $token ) ) {
929            return $token;
930        }
931
932        $args = array(
933            'method'  => $method,
934            'timeout' => 30,
935            'headers' => array_merge(
936                array(
937                    'Authorization' => 'Bearer ' . $token,
938                    'Content-Type'  => 'application/json',
939                    'Accept'        => 'application/json',
940                ),
941                $extra_headers
942            ),
943        );
944
945        if ( null !== $body && in_array( $method, array( 'POST', 'PUT' ), true ) ) {
946            $args['body'] = wp_json_encode( $body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
947        }
948
949        $response = wp_remote_request( $base_url . $path, $args );
950
951        if ( is_wp_error( $response ) ) {
952            return new WP_Error(
953                'paypal_request_failed',
954                $response->get_error_message(),
955                array( 'status' => 502 )
956            );
957        }
958
959        $status_code = (int) wp_remote_retrieve_response_code( $response );
960        if ( ( $status_code < 200 || $status_code >= 300 ) && ! in_array( $status_code, $unlogged_statuses, true ) ) {
961            $this->log_paypal_error( $response, $status_code, $method, $path );
962        }
963
964        return $response;
965    }
966
967    /**
968     * Log a PayPal error with its debug ID, which PayPal support uses to trace the call.
969     * Logging errors are caught so the PayPal call still returns.
970     *
971     * @param array  $response    The wp_remote_request() response.
972     * @param int    $status_code PayPal's HTTP status.
973     * @param string $method      HTTP method.
974     * @param string $path        API path, with any query string.
975     */
976    private function log_paypal_error( $response, $status_code, $method, $path ) {
977        try {
978            // A 401 has the debug ID only in the header. Fall back to the body.
979            $debug_id = wp_remote_retrieve_header( $response, 'paypal-debug-id' );
980            if ( ! is_string( $debug_id ) || '' === $debug_id ) {
981                $debug_id = self::decode_body( $response )['debug_id'] ?? '';
982            }
983
984            // Use site_id(). For a self-hosted caller the current blog is public-api's.
985            $extra = array(
986                'debug_id' => $debug_id,
987                'status'   => $status_code,
988                'method'   => $method,
989                'path'     => $path,
990                'site_id'  => $this->site_id(),
991            );
992
993            /**
994             * Filters whether to send PayPal API errors to logstash.
995             *
996             * @param bool  $enabled Whether to send the entry. Default true.
997             * @param array $extra   The entry's data.
998             */
999            if ( ! (bool) apply_filters( 'wpcom_paypal_payment_buttons_log_enabled', true, $extra ) ) {
1000                return;
1001            }
1002
1003            \Automattic\Jetpack\Jetpack_Mu_Wpcom::log2logstash( 'paypal_payment_buttons', 'api_error', $extra );
1004        } catch ( \Throwable $e ) {
1005            unset( $e );
1006        }
1007    }
1008
1009    /**
1010     * PayPal's API host for an environment.
1011     *
1012     * @param string $environment 'sandbox' or 'production'.
1013     * @return string
1014     */
1015    private function base_url( $environment ) {
1016        return 'production' === $environment
1017            ? self::PAYPAL_PRODUCTION_BASE_URL
1018            : self::PAYPAL_SANDBOX_BASE_URL;
1019    }
1020
1021    /**
1022     * Get Automattic's PayPal platform credentials for the given environment.
1023     *
1024     * @param string $environment 'sandbox' or 'production'.
1025     * @return array|WP_Error Array with 'client_id', 'client_secret' and 'partner_merchant_id', or WP_Error.
1026     */
1027    private function get_platform_credentials( $environment ) {
1028        $constants = self::PLATFORM_CREDENTIAL_CONSTANTS[ $environment ] ?? array();
1029
1030        $credentials = array();
1031        $missing     = array();
1032
1033        foreach ( $constants as $key => $constant_name ) {
1034            $value = Constants::get_constant( $constant_name );
1035            if ( is_string( $value ) && '' !== $value ) {
1036                $credentials[ $key ] = $value;
1037            } else {
1038                $missing[] = $constant_name;
1039            }
1040        }
1041
1042        if ( empty( $missing ) ) {
1043            return $credentials;
1044        }
1045
1046        /*
1047         * Separate "nothing is provisioned here at all" from "this environment is
1048         * not provisioned". The option this replaced could tell them apart because
1049         * one value held every environment; per-environment constants cannot, so
1050         * look at the whole set. The second message is the actionable one, and it
1051         * is what a sandbox-only configuration hits when asked for production.
1052         */
1053        $anything_provisioned = false;
1054        foreach ( self::PLATFORM_CREDENTIAL_CONSTANTS as $environment_constants ) {
1055            foreach ( $environment_constants as $constant_name ) {
1056                $value = Constants::get_constant( $constant_name );
1057                if ( is_string( $value ) && '' !== $value ) {
1058                    $anything_provisioned = true;
1059                    break 2;
1060                }
1061            }
1062        }
1063
1064        if ( ! $anything_provisioned ) {
1065            return new WP_Error(
1066                'platform_credentials_missing',
1067                'PayPal platform credentials are not configured on WordPress.com. Please contact the Jetpack team.',
1068                array( 'status' => 500 )
1069            );
1070        }
1071
1072        /*
1073         * Name the constants that are absent. "not configured" on its own sends
1074         * whoever is provisioning the environment hunting through three names to
1075         * find which one they missed.
1076         *
1077         * The partner merchant ID keeps its own code: it is Automattic's own
1078         * PayPal account ID rather than an API credential, it is easy to overlook
1079         * because the referral link is generated without it, and the flow only
1080         * breaks later -- when the seller has already finished onboarding.
1081         */
1082        $partner_constant  = $constants['partner_merchant_id'] ?? '';
1083        $only_partner_id   = array( $partner_constant ) === $missing;
1084        $error_code        = $only_partner_id
1085            ? 'platform_partner_merchant_id_missing'
1086            : 'platform_credentials_invalid';
1087        $missing_explained = $only_partner_id
1088            ? 'Onboarding cannot be completed without it.'
1089            : 'Onboarding cannot be started without them.';
1090
1091        return new WP_Error(
1092            $error_code,
1093            sprintf(
1094                'PayPal platform credentials for the %1$s environment are incomplete. Missing: %2$s. %3$s',
1095                $environment,
1096                implode( ', ', $missing ),
1097                $missing_explained
1098            ),
1099            array( 'status' => 500 )
1100        );
1101    }
1102
1103    /**
1104     * Transient key for the cached platform token of one environment.
1105     *
1106     * @param string $environment 'sandbox' or 'production'.
1107     * @return string
1108     */
1109    public static function token_cache_key( $environment ) {
1110        return 'paypal_platform_token_' . $environment;
1111    }
1112
1113    /**
1114     * Get a PayPal OAuth access token using client credentials grant.
1115     *
1116     * Cached until shortly before it expires: with every button operation now
1117     * passing through here, a token exchange per call would double the traffic.
1118     *
1119     * @param string $environment   'sandbox' or 'production'.
1120     * @param string $base_url      PayPal API base URL.
1121     * @param string $client_id     Platform client ID.
1122     * @param string $client_secret Platform client secret.
1123     * @return string|WP_Error Access token string, or WP_Error.
1124     */
1125    private function get_paypal_access_token( $environment, $base_url, $client_id, $client_secret ) {
1126        $cached = get_transient( self::token_cache_key( $environment ) );
1127        if ( is_string( $cached ) && '' !== $cached ) {
1128            return $cached;
1129        }
1130
1131        $response = wp_remote_post(
1132            $base_url . self::PAYPAL_TOKEN_ENDPOINT,
1133            array(
1134                'timeout' => 15,
1135                'headers' => array(
1136                    'Authorization' => 'Basic ' . base64_encode( $client_id . ':' . $client_secret ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required by PayPal OAuth spec.
1137                    'Content-Type'  => 'application/x-www-form-urlencoded',
1138                    'Accept'        => 'application/json',
1139                ),
1140                'body'    => 'grant_type=client_credentials',
1141            )
1142        );
1143
1144        if ( is_wp_error( $response ) ) {
1145            return new WP_Error(
1146                'paypal_token_failed',
1147                $response->get_error_message(),
1148                array( 'status' => 502 )
1149            );
1150        }
1151
1152        $status_code = (int) wp_remote_retrieve_response_code( $response );
1153        $data        = self::decode_body( $response );
1154
1155        if ( 200 !== $status_code ) {
1156            $this->log_paypal_error( $response, $status_code, 'POST', self::PAYPAL_TOKEN_ENDPOINT );
1157        }
1158
1159        if ( 200 !== $status_code || empty( $data['access_token'] ) ) {
1160            return new WP_Error(
1161                'paypal_token_error',
1162                'Failed to obtain PayPal access token with platform credentials.',
1163                array( 'status' => 502 )
1164            );
1165        }
1166
1167        $expires_in = isset( $data['expires_in'] ) ? (int) $data['expires_in'] : 0;
1168        if ( $expires_in > self::TOKEN_EXPIRY_BUFFER ) {
1169            set_transient( self::token_cache_key( $environment ), $data['access_token'], $expires_in - self::TOKEN_EXPIRY_BUFFER );
1170        }
1171
1172        return $data['access_token'];
1173    }
1174}
1175
1176wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding' );