Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.65% covered (success)
97.65%
83 / 85
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
PayPal_Platform_Client
100.00% covered (success)
100.00%
83 / 83
100.00% covered (success)
100.00%
4 / 4
20
100.00% covered (success)
100.00%
1 / 1
 request
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
5
 get_merchant_integration
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 call_wpcom
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
6
 wpcom_error
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2/**
3 * PayPal calls made through WordPress.com on a referred seller's behalf.
4 *
5 * A seller onboarded through Partner Referrals (THIRD_PARTY) holds no API
6 * credentials on the site. Automattic's platform credentials live on
7 * WordPress.com, so every PayPal call for that seller is made there, signed
8 * with a PayPal-Auth-Assertion naming the seller.
9 *
10 * @package automattic/jetpack-paypal-payments
11 * @since 0.12.0
12 */
13
14namespace Automattic\Jetpack\PaypalPayments;
15
16use Automattic\Jetpack\Connection\Client;
17
18if ( ! defined( 'ABSPATH' ) ) {
19    exit;
20}
21
22/**
23 * Class PayPal_Platform_Client
24 */
25class PayPal_Platform_Client {
26
27    /**
28     * WordPress.com route that forwards one Payment Links & Buttons call.
29     *
30     * @var string
31     */
32    const WPCOM_REQUEST_ROUTE = '/paypal/platform/request';
33
34    /**
35     * WordPress.com route that reads a referred seller's integration status.
36     *
37     * @var string
38     */
39    const WPCOM_MERCHANT_INTEGRATION_ROUTE = '/paypal/platform/merchant-integration';
40
41    /**
42     * Make one Payment Links & Buttons call for the connected seller.
43     *
44     * PayPal's own status and body come back in the shape wp_remote_request()
45     * returns, so callers read the answer the same way as a direct call.
46     *
47     * @param string     $method     HTTP method (GET, POST, PUT, DELETE).
48     * @param string     $path       PayPal API path, with any query string.
49     * @param array|null $body       Request body for POST and PUT.
50     * @param string     $request_id PayPal-Request-Id idempotency key.
51     * @return array|\WP_Error A wp_remote_request()-shaped response, or WP_Error when the call never reached PayPal.
52     */
53    public static function request( $method, $path, $body = null, $request_id = '' ) {
54        $merchant_id = PayPal_Partner_Onboarding::get_merchant_id();
55        if ( '' === $merchant_id ) {
56            return new \WP_Error(
57                'paypal_no_credentials',
58                __( 'PayPal API credentials are not configured. Please connect your PayPal account.', 'jetpack-paypal-payments' )
59            );
60        }
61
62        $params = array(
63            'environment' => PayPal_OAuth::get_environment(),
64            'merchant_id' => $merchant_id,
65            'method'      => $method,
66            'path'        => $path,
67            'body'        => $body,
68            'request_id'  => $request_id,
69        );
70
71        // Proof this site referred the seller; a site onboarded before it was kept has none.
72        $tracking_id = PayPal_Partner_Onboarding::get_referral_tracking_id();
73        if ( '' !== $tracking_id ) {
74            $params['tracking_id'] = $tracking_id;
75        }
76
77        $result = self::call_wpcom( 'POST', self::WPCOM_REQUEST_ROUTE, $params );
78
79        if ( is_wp_error( $result ) ) {
80            return $result;
81        }
82
83        if ( ! isset( $result['status'] ) ) {
84            return new \WP_Error(
85                'paypal_platform_invalid_response',
86                __( 'WordPress.com returned an unexpected answer from PayPal.', 'jetpack-paypal-payments' ),
87                array( 'status' => 502 )
88            );
89        }
90
91        return array(
92            'headers'       => array(),
93            'body'          => (string) ( $result['body'] ?? '' ),
94            'response'      => array(
95                'code'    => (int) $result['status'],
96                'message' => '',
97            ),
98            'cookies'       => array(),
99            'http_response' => null,
100        );
101    }
102
103    /**
104     * Read a referred seller's integration record.
105     *
106     * @param string $merchant_id The seller's PayPal merchant ID, when known.
107     * @param string $tracking_id The referral's tracking ID: alone, to find a seller who just finished
108     *                            onboarding; with a merchant ID, as proof this site referred them.
109     * @return array|\WP_Error PayPal's merchant integration, or WP_Error.
110     */
111    public static function get_merchant_integration( $merchant_id = '', $tracking_id = '' ) {
112        $params = array( 'environment' => PayPal_OAuth::get_environment() );
113        if ( '' !== $merchant_id ) {
114            $params['merchant_id'] = $merchant_id;
115        }
116        if ( '' !== $tracking_id ) {
117            $params['tracking_id'] = $tracking_id;
118        }
119
120        return self::call_wpcom( 'GET', self::WPCOM_MERCHANT_INTEGRATION_ROUTE, $params );
121    }
122
123    /**
124     * Call one WordPress.com platform route as the blog.
125     *
126     * @param string $method HTTP method.
127     * @param string $route  Route below wpcom/v2.
128     * @param array  $params Query arguments for GET, JSON body otherwise.
129     * @return array|\WP_Error The decoded response, or WP_Error carrying WordPress.com's code and status.
130     */
131    private static function call_wpcom( $method, $route, $params ) {
132        $body = null;
133        if ( 'GET' === $method ) {
134            $route = add_query_arg( array_map( 'rawurlencode', $params ), $route );
135        } else {
136            $body = wp_json_encode( $params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
137        }
138
139        $response = Client::wpcom_json_api_request_as_blog(
140            $route,
141            '2',
142            array(
143                'method'  => $method,
144                'timeout' => 30,
145                'headers' => array(
146                    'Content-Type' => 'application/json',
147                    'Accept'       => 'application/json',
148                ),
149            ),
150            $body,
151            'wpcom'
152        );
153
154        if ( is_wp_error( $response ) ) {
155            return new \WP_Error(
156                'paypal_api_request_failed',
157                sprintf(
158                    /* translators: %s: error message from the HTTP request */
159                    __( 'PayPal API request failed: %s', 'jetpack-paypal-payments' ),
160                    $response->get_error_message()
161                ),
162                array( 'status' => 0 )
163            );
164        }
165
166        $status_code = (int) wp_remote_retrieve_response_code( $response );
167        $data        = json_decode( wp_remote_retrieve_body( $response ), true );
168
169        if ( 200 !== $status_code || ! is_array( $data ) ) {
170            return self::wpcom_error( $status_code, is_array( $data ) ? $data : array() );
171        }
172
173        return $data;
174    }
175
176    /**
177     * Turn a WordPress.com error answer into a WP_Error the site can act on.
178     *
179     * The code, message and data are WordPress.com's own, so a merchant this
180     * site did not refer, or an unprovisioned platform, is reported as such.
181     *
182     * @param int   $status_code HTTP status from WordPress.com.
183     * @param array $data        Decoded error body.
184     * @return \WP_Error
185     */
186    private static function wpcom_error( $status_code, array $data ) {
187        $error_data = isset( $data['data'] ) && is_array( $data['data'] ) ? $data['data'] : array();
188
189        // 0 means WordPress.com itself never answered.
190        $error_data['status'] = $status_code > 0 ? $status_code : 503;
191
192        return new \WP_Error(
193            ! empty( $data['code'] ) ? sanitize_key( $data['code'] ) : 'paypal_platform_request_failed',
194            ! empty( $data['message'] )
195                ? sanitize_text_field( $data['message'] )
196                : __( 'WordPress.com could not reach PayPal on your behalf. Please try again.', 'jetpack-paypal-payments' ),
197            $error_data
198        );
199    }
200}