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