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