Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.79% covered (success)
93.79%
166 / 177
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding
95.40% covered (success)
95.40%
166 / 174
83.33% covered (warning)
83.33%
5 / 6
38
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%
27 / 27
100.00% covered (success)
100.00%
1 / 1
2
 permission_check
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 generate_signup_link
100.00% covered (success)
100.00%
66 / 66
100.00% covered (success)
100.00%
1 / 1
17
 get_platform_credentials
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
12
 get_paypal_access_token
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * WPCOM REST API v2 endpoint for PayPal Partner Referrals onboarding.
4 *
5 * Proxies signup link generation through WordPress.com so that Automattic's
6 * PayPal platform credentials (client_id/client_secret) stay server-side
7 * and never ship in the plugin.
8 *
9 * Plugin side: PayPal_Partner_Onboarding::generate_signup_link() calls this
10 * endpoint via Client::wpcom_json_api_request_as_blog().
11 *
12 * @package automattic/jetpack-mu-wpcom
13 * @since $$next-version$$
14 * @see https://developer.paypal.com/docs/multiparty/seller-onboarding/build-onboarding/
15 */
16
17use Automattic\Jetpack\Connection\Manager as Connection_Manager;
18use Automattic\Jetpack\Constants;
19use Automattic\Jetpack\Feature_Flags\Feature_Flags;
20
21if ( ! defined( 'ABSPATH' ) ) {
22    exit( 0 );
23}
24
25/**
26 * PayPal Onboarding: Generate Partner Referrals signup links.
27 *
28 * Receives a referral request body from the plugin, authenticates with PayPal
29 * using Automattic's platform credentials, and returns the action_url for the
30 * merchant's onboarding popup.
31 *
32 * @since $$next-version$$
33 */
34class WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding extends WP_REST_Controller {
35
36    /**
37     * PayPal production API base URL.
38     *
39     * @var string
40     */
41    const PAYPAL_PRODUCTION_BASE_URL = 'https://api.paypal.com';
42
43    /**
44     * PayPal sandbox API base URL.
45     *
46     * @var string
47     */
48    const PAYPAL_SANDBOX_BASE_URL = 'https://api-m.sandbox.paypal.com';
49
50    /**
51     * PayPal Partner Referrals API endpoint.
52     *
53     * @var string
54     */
55    const PAYPAL_REFERRALS_ENDPOINT = '/v2/customer/partner-referrals';
56
57    /**
58     * PayPal OAuth token endpoint.
59     *
60     * @var string
61     */
62    const PAYPAL_TOKEN_ENDPOINT = '/v1/oauth2/token';
63
64    /**
65     * Names of the constants holding Automattic's PayPal platform credentials, by environment.
66     *
67     * The values themselves live in WordPress.com's secrets configuration. Only the
68     * constant *names* are stored here: referencing an undefined constant inside a
69     * constant expression is a fatal error, and these are never defined on self-hosted
70     * sites, where onboarding is proxied to WordPress.com instead. Read them through
71     * get_platform_credentials(), which tolerates their absence.
72     *
73     * @var array<string, array<string, string>>
74     */
75    const PLATFORM_CREDENTIAL_CONSTANTS = array(
76        'production' => array(
77            'client_id'           => 'PAYPAL_BUTTONS_PRODUCTION_CLIENT_ID',
78            'client_secret'       => 'PAYPAL_BUTTONS_PRODUCTION_CLIENT_SECRET',
79            'partner_merchant_id' => 'PAYPAL_BUTTONS_PRODUCTION_PARTNER_MERCHANT_ID',
80        ),
81        'sandbox'    => array(
82            'client_id'           => 'PAYPAL_BUTTONS_SANDBOX_CLIENT_ID',
83            'client_secret'       => 'PAYPAL_BUTTONS_SANDBOX_CLIENT_SECRET',
84            'partner_merchant_id' => 'PAYPAL_BUTTONS_SANDBOX_PARTNER_MERCHANT_ID',
85        ),
86    );
87
88    /**
89     * Constructor.
90     */
91    public function __construct() {
92        $this->namespace = 'wpcom/v2';
93
94        /*
95         * 'paypal/platform', not 'paypal/onboarding': the package registers the
96         * editor-facing wpcom/v2/paypal/onboarding/signup-link on every host that
97         * runs it, including this one. Sharing the path would mean two classes
98         * claiming one route, and the site proxying to itself.
99         */
100        $this->rest_base = 'paypal/platform';
101
102        /*
103         * Opt out of WordPress.com's centralize.php rewrite, which otherwise moves every
104         * wpcom/v2 route to /wpcom/v2/sites/<site>/... . Client::wpcom_json_api_request_as_blog()
105         * builds a flat /wpcom/v2/<path> URL -- the blog ID travels as a signed argument, not
106         * in the path -- so a rewritten route is unreachable from it and every call came back
107         * rest_no_route.
108         *
109         * The flat form is also the honest one here: this endpoint carries no per-site data.
110         * It exchanges Automattic's platform credentials for a PayPal referral link, and the
111         * merchant is identified by the referral body, not by a site path segment.
112         *
113         * The wpcom-only flag stops public-api's proxy_jetpack() from forwarding the call
114         * to a Jetpack site and answering rest_not_implemented, which is right here: the
115         * credentials are Automattic's and live on WordPress.com servers. A false
116         * site_specific already implies wpcom-only, but both are set explicitly -- as
117         * WPCOM_REST_API_V2_Endpoint_Following does -- so neither relies on the other's
118         * side effect.
119         */
120        $this->wpcom_is_wpcom_only_endpoint    = true;
121        $this->wpcom_is_site_specific_endpoint = false;
122
123        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
124    }
125
126    /**
127     * Register REST API routes.
128     */
129    public function register_routes() {
130        // Hard-coded: mu-wpcom cannot reach PayPal_Payment_Buttons::API_MANAGED_BUTTONS_FLAG.
131        // Unregistered here, so only a `jetpack_feature_flag_enabled_*` filter flips it on wpcom.
132        if ( ! Feature_Flags::is_enabled( 'paypal-payments-api-managed-buttons' ) ) {
133            return;
134        }
135
136        register_rest_route(
137            $this->namespace,
138            $this->rest_base . '/signup-link',
139            array(
140                array(
141                    'methods'             => WP_REST_Server::CREATABLE,
142                    'callback'            => array( $this, 'generate_signup_link' ),
143                    'permission_callback' => array( $this, 'permission_check' ),
144                    'args'                => array(
145                        'environment' => array(
146                            'required'          => true,
147                            'type'              => 'string',
148                            'enum'              => array( 'sandbox', 'production' ),
149                            'sanitize_callback' => 'sanitize_text_field',
150                            'description'       => 'PayPal environment: sandbox or production.',
151                        ),
152                        'referral'    => array(
153                            'required'    => true,
154                            'type'        => 'object',
155                            'description' => 'Partner Referrals request body to forward to PayPal.',
156                        ),
157                    ),
158                ),
159            )
160        );
161    }
162
163    /**
164     * Permission check â€” requires a valid Jetpack blog connection.
165     *
166     * The request comes from the plugin via Client::wpcom_json_api_request_as_blog(),
167     * which authenticates using the site's Jetpack blog token.
168     *
169     * @return true|WP_Error
170     */
171    public function permission_check() {
172        $site_id = Connection_Manager::get_site_id();
173        if ( is_wp_error( $site_id ) ) {
174            return new WP_Error(
175                'not_connected',
176                __( 'Site is not connected to WordPress.com.', 'jetpack-mu-wpcom' ),
177                array( 'status' => 403 )
178            );
179        }
180        return true;
181    }
182
183    /**
184     * Generate a PayPal Partner Referrals signup link.
185     *
186     * Authenticates with PayPal using Automattic's platform credentials,
187     * creates a partner referral, and returns the action_url.
188     *
189     * @param WP_REST_Request $request The REST request.
190     * @return WP_REST_Response|WP_Error
191     */
192    public function generate_signup_link( WP_REST_Request $request ) {
193        $environment = $request->get_param( 'environment' );
194        $referral    = $request->get_param( 'referral' );
195
196        // Step 1: Load Automattic's PayPal platform credentials.
197        $credentials = $this->get_platform_credentials( $environment );
198        if ( is_wp_error( $credentials ) ) {
199            return $credentials;
200        }
201
202        $base_url = 'production' === $environment
203            ? self::PAYPAL_PRODUCTION_BASE_URL
204            : self::PAYPAL_SANDBOX_BASE_URL;
205
206        // Step 2: Get an access token using Automattic's platform credentials.
207        $token = $this->get_paypal_access_token( $base_url, $credentials['client_id'], $credentials['client_secret'] );
208        if ( is_wp_error( $token ) ) {
209            return $token;
210        }
211
212        // Step 3: Create the Partner Referral via PayPal API.
213        $response = wp_remote_post(
214            $base_url . self::PAYPAL_REFERRALS_ENDPOINT,
215            array(
216                'timeout' => 30,
217                'headers' => array(
218                    'Authorization' => 'Bearer ' . $token,
219                    'Content-Type'  => 'application/json',
220                    'Accept'        => 'application/json',
221                ),
222                'body'    => wp_json_encode( $referral, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
223            )
224        );
225
226        if ( is_wp_error( $response ) ) {
227            return new WP_Error(
228                'paypal_request_failed',
229                $response->get_error_message(),
230                array( 'status' => 502 )
231            );
232        }
233
234        $status_code = wp_remote_retrieve_response_code( $response );
235        $body        = json_decode( wp_remote_retrieve_body( $response ), true );
236
237        if ( 201 !== $status_code && 200 !== $status_code ) {
238            /*
239             * PayPal's top-level message for a rejected referral is always the same
240             * generic sentence ("Request is not well-formed, syntactically
241             * incorrect, or violates schema."). Everything needed to act on it is
242             * in `details`, which names the offending field and issue, and in
243             * `debug_id`, which PayPal support needs to trace the call. Passing
244             * only the message through left callers with nothing to go on, so
245             * carry both. None of it is credential material.
246             */
247            $error_data = array( 'status' => $status_code );
248
249            if ( ! empty( $body['name'] ) ) {
250                $error_data['paypal_error'] = $body['name'];
251            }
252            if ( ! empty( $body['details'] ) && is_array( $body['details'] ) ) {
253                $error_data['paypal_details'] = $body['details'];
254            }
255            if ( ! empty( $body['debug_id'] ) ) {
256                $error_data['paypal_debug_id'] = $body['debug_id'];
257            }
258
259            return new WP_Error(
260                'paypal_referral_failed',
261                $body['message'] ?? 'PayPal Partner Referrals API returned an error.',
262                $error_data
263            );
264        }
265
266        // Step 4: Extract action_url and referral_id from PayPal's response.
267        $action_url  = '';
268        $referral_id = '';
269
270        if ( isset( $body['links'] ) && is_array( $body['links'] ) ) {
271            foreach ( $body['links'] as $link ) {
272                if ( 'action_url' === $link['rel'] ) {
273                    $action_url = $link['href'];
274                }
275                if ( 'self' === $link['rel'] ) {
276                    $parts       = explode( '/', $link['href'] );
277                    $referral_id = end( $parts );
278                }
279            }
280        }
281
282        if ( empty( $action_url ) ) {
283            return new WP_Error(
284                'paypal_no_action_url',
285                'PayPal returned a successful response but no onboarding URL was included.',
286                array( 'status' => 502 )
287            );
288        }
289
290        return rest_ensure_response(
291            array(
292                'action_url'          => $action_url,
293                'referral_id'         => $referral_id,
294                // The plugin needs this to address PayPal as the partner when it
295                // exchanges the auth code and when it checks merchant status.
296                'partner_merchant_id' => $credentials['partner_merchant_id'] ?? '',
297            )
298        );
299    }
300
301    /**
302     * Get Automattic's PayPal platform credentials for the given environment.
303     *
304     * @param string $environment 'sandbox' or 'production'.
305     * @return array|WP_Error Array with 'client_id' and 'client_secret', or WP_Error.
306     */
307    private function get_platform_credentials( $environment ) {
308        $constants = self::PLATFORM_CREDENTIAL_CONSTANTS[ $environment ] ?? array();
309
310        $credentials = array();
311        $missing     = array();
312
313        foreach ( $constants as $key => $constant_name ) {
314            $value = Constants::get_constant( $constant_name );
315            if ( is_string( $value ) && '' !== $value ) {
316                $credentials[ $key ] = $value;
317            } else {
318                $missing[] = $constant_name;
319            }
320        }
321
322        if ( empty( $missing ) ) {
323            return $credentials;
324        }
325
326        /*
327         * Separate "nothing is provisioned here at all" from "this environment is
328         * not provisioned". The option this replaced could tell them apart because
329         * one value held every environment; per-environment constants cannot, so
330         * look at the whole set. The second message is the actionable one, and it
331         * is what a sandbox-only configuration hits when asked for production.
332         */
333        $anything_provisioned = false;
334        foreach ( self::PLATFORM_CREDENTIAL_CONSTANTS as $environment_constants ) {
335            foreach ( $environment_constants as $constant_name ) {
336                $value = Constants::get_constant( $constant_name );
337                if ( is_string( $value ) && '' !== $value ) {
338                    $anything_provisioned = true;
339                    break 2;
340                }
341            }
342        }
343
344        if ( ! $anything_provisioned ) {
345            return new WP_Error(
346                'platform_credentials_missing',
347                'PayPal platform credentials are not configured on WordPress.com. Please contact the Jetpack team.',
348                array( 'status' => 500 )
349            );
350        }
351
352        /*
353         * Name the constants that are absent. "not configured" on its own sends
354         * whoever is provisioning the environment hunting through three names to
355         * find which one they missed.
356         *
357         * The partner merchant ID keeps its own code: it is Automattic's own
358         * PayPal account ID rather than an API credential, it is easy to overlook
359         * because the referral link is generated without it, and the flow only
360         * breaks later -- when the seller has already finished onboarding.
361         */
362        $partner_constant  = $constants['partner_merchant_id'] ?? '';
363        $only_partner_id   = array( $partner_constant ) === $missing;
364        $error_code        = $only_partner_id
365            ? 'platform_partner_merchant_id_missing'
366            : 'platform_credentials_invalid';
367        $missing_explained = $only_partner_id
368            ? 'Onboarding cannot be completed without it.'
369            : 'Onboarding cannot be started without them.';
370
371        return new WP_Error(
372            $error_code,
373            sprintf(
374                'PayPal platform credentials for the %1$s environment are incomplete. Missing: %2$s. %3$s',
375                $environment,
376                implode( ', ', $missing ),
377                $missing_explained
378            ),
379            array( 'status' => 500 )
380        );
381    }
382
383    /**
384     * Get a PayPal OAuth access token using client credentials grant.
385     *
386     * @param string $base_url      PayPal API base URL.
387     * @param string $client_id     Platform client ID.
388     * @param string $client_secret Platform client secret.
389     * @return string|WP_Error Access token string, or WP_Error.
390     */
391    private function get_paypal_access_token( $base_url, $client_id, $client_secret ) {
392        $response = wp_remote_post(
393            $base_url . self::PAYPAL_TOKEN_ENDPOINT,
394            array(
395                'timeout' => 15,
396                'headers' => array(
397                    'Authorization' => 'Basic ' . base64_encode( $client_id . ':' . $client_secret ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required by PayPal OAuth spec.
398                    'Content-Type'  => 'application/x-www-form-urlencoded',
399                    'Accept'        => 'application/json',
400                ),
401                'body'    => 'grant_type=client_credentials',
402            )
403        );
404
405        if ( is_wp_error( $response ) ) {
406            return new WP_Error(
407                'paypal_token_failed',
408                $response->get_error_message(),
409                array( 'status' => 502 )
410            );
411        }
412
413        $status_code = wp_remote_retrieve_response_code( $response );
414        $data        = json_decode( wp_remote_retrieve_body( $response ), true );
415
416        if ( 200 !== $status_code || empty( $data['access_token'] ) ) {
417            return new WP_Error(
418                'paypal_token_error',
419                'Failed to obtain PayPal access token with platform credentials.',
420                array( 'status' => 502 )
421            );
422        }
423
424        return $data['access_token'];
425    }
426}
427
428wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_PayPal_Onboarding' );