Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.66% covered (success)
90.66%
262 / 289
85.71% covered (warning)
85.71%
18 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_API_Client
91.29% covered (success)
91.29%
262 / 287
85.71% covered (warning)
85.71%
18 / 21
106.61
0.00% covered (danger)
0.00%
0 / 1
 create_resource
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 list_resources
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 list_resources_cached
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_resource_cached
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 forget_cached_resources
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 resource_cache_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_resource
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 update_resource
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 delete_resource
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 remember_deleted_resource
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 is_deleted_resource
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleted_resources
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 make_request_with_retry
63.64% covered (warning)
63.64%
28 / 44
0.00% covered (danger)
0.00%
0 / 1
28.31
 make_request
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 make_direct_request
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
8
 parse_error_response
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 get_user_friendly_message
76.67% covered (warning)
76.67%
23 / 30
0.00% covered (danger)
0.00%
0 / 1
15.15
 extract_field_errors
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 validate_paypal_url
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 extract_payment_link
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 sanitize_resource_id
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * PayPal Pay Links & Buttons API client.
4 *
5 * Provides typed CRUD operations for the /v1/checkout/payment-resources
6 * endpoint. All requests are authenticated via PayPal_OAuth and include
7 * a PayPal-Request-Id header for idempotency.
8 *
9 * Note: The PayPal-Partner-Attribution-Id header is NOT supported on
10 * Payment Links API endpoints. BN code attribution is applied via the
11 * `at_code` query parameter on payment link URLs instead (see
12 * PayPal_Payment_Buttons::render_api_managed_button).
13 *
14 * Updated for WOOPTP-151: Token auto-refresh on 403 with retry,
15 * network timeout handling with exponential backoff, and PayPal
16 * URL domain whitelist validation.
17 *
18 * @package automattic/jetpack-paypal-payments
19 * @since 0.7.0
20 */
21
22namespace Automattic\Jetpack\PaypalPayments;
23
24if ( ! defined( 'ABSPATH' ) ) {
25    exit;
26}
27
28/**
29 * Class PayPal_API_Client
30 *
31 * Wraps PayPal's Pay Links & Buttons API with typed methods for
32 * creating, listing, getting, updating, and deleting payment resources.
33 */
34class PayPal_API_Client {
35
36    /**
37     * Payment resources API endpoint path.
38     *
39     * @var string
40     */
41    const RESOURCES_ENDPOINT = '/v1/checkout/payment-resources';
42
43    /**
44     * Counter folded into every cached list key, bumped whenever a payment link
45     * is created, updated or deleted. Cached pages are keyed by PayPal's opaque
46     * page token, so they cannot be enumerated and deleted one by one.
47     *
48     * @var string
49     */
50    const LIST_CACHE_VERSION_OPTION = 'jetpack_paypal_payment_buttons_list_cache_version';
51
52    /**
53     * How long a cached list page is served, in seconds.
54     *
55     * @var int
56     */
57    const LIST_CACHE_TTL = 60;
58
59    /**
60     * How long a cached single resource is served, in seconds.
61     *
62     * @var int
63     */
64    const RESOURCE_CACHE_TTL = 300;
65
66    /**
67     * Links deleted through this site, newest first, so their blocks stop rendering.
68     *
69     * @var string
70     */
71    const DELETED_RESOURCES_OPTION = 'jetpack_paypal_payment_buttons_deleted_resources';
72
73    /**
74     * How many deleted links are remembered. Older ones fall off the end.
75     *
76     * @var int
77     */
78    const DELETED_RESOURCES_LIMIT = 100;
79
80    /**
81     * Default timeout for API requests in seconds.
82     *
83     * @var int
84     */
85    const REQUEST_TIMEOUT = 30;
86
87    /**
88     * Maximum number of retry attempts for server errors.
89     *
90     * @var int
91     */
92    const MAX_RETRIES = 3;
93
94    /**
95     * Base delay in seconds for exponential backoff.
96     *
97     * @var float
98     */
99    const BACKOFF_BASE_SECONDS = 1.0;
100
101    /**
102     * Allowed PayPal domains for payment link URLs.
103     *
104     * Checked by validate_paypal_url() on a link PayPal returns, and by
105     * PayPal_Payment_Buttons::sanitize_paypal_script_url() on a URL stored in a block
106     * attribute. The editor keeps its own copy in utils/validation.js, and
107     * test_paypal_host_allow_lists_are_in_sync() compares the two.
108     *
109     * @var array
110     */
111    const ALLOWED_PAYPAL_DOMAINS = array(
112        'www.paypal.com',
113        'www.sandbox.paypal.com',
114        'paypal.com',
115        'sandbox.paypal.com',
116    );
117
118    /**
119     * Create a payment resource (button/link).
120     *
121     * @param array $resource_data {
122     *     Payment resource data.
123     *
124     *     @type string $type             Payment type. Currently only 'BUY_NOW'.
125     *     @type string $integration_mode 'LINK' or 'BUTTON'.
126     *     @type string $reusable         'MULTIPLE' (default) -- link reusable.
127     *     @type string $return_url       Optional redirect after payment.
128     *     @type array  $line_items       Required. Array of line item objects.
129     * }
130     * @return array|\WP_Error Decoded response body on success (HTTP 201), WP_Error on failure.
131     */
132    public static function create_resource( $resource_data ) {
133        $result = self::make_request_with_retry(
134            'POST',
135            self::RESOURCES_ENDPOINT,
136            $resource_data,
137            201
138        );
139
140        if ( is_wp_error( $result ) ) {
141            return $result;
142        }
143
144        self::forget_cached_resources();
145
146        // Extract payment_link from HATEOAS links array to top-level field.
147        $result = self::extract_payment_link( $result );
148
149        // Validate payment_link domain if present.
150        if ( ! empty( $result['payment_link'] ) ) {
151            $validation = self::validate_paypal_url( $result['payment_link'] );
152            if ( is_wp_error( $validation ) ) {
153                return $validation;
154            }
155        }
156
157        return $result;
158    }
159
160    /**
161     * List payment resources with optional pagination.
162     *
163     * The 10 is PayPal's own default when the parameter is omitted. Callers that
164     * care state their own - the REST route asks for 100, the admin table PER_PAGE.
165     *
166     * @param int    $page_size  Number of results per page. Default 10.
167     * @param string $page_token Pagination cursor from a previous response. Default empty.
168     * @return array|\WP_Error Decoded response body on success (HTTP 200), WP_Error on failure.
169     */
170    public static function list_resources( $page_size = 10, $page_token = '' ) {
171        $query_args = array(
172            'page_size'      => absint( $page_size ),
173            // PayPal omits total_items and total_pages unless we ask for them.
174            'total_required' => 'true',
175        );
176
177        if ( ! empty( $page_token ) ) {
178            $query_args['page_token'] = sanitize_text_field( $page_token );
179        }
180
181        $endpoint = add_query_arg( $query_args, self::RESOURCES_ENDPOINT );
182
183        return self::make_request_with_retry( 'GET', $endpoint, null, 200 );
184    }
185
186    /**
187     * List payment resources, served from a short cache.
188     *
189     * Every write through this class invalidates the cache, so a page reads fresh
190     * right after a create, update or delete.
191     *
192     * @since 0.9.0
193     *
194     * @param int    $page_size  Number of results per page.
195     * @param string $page_token Pagination cursor from a previous response. Default empty.
196     * @return array|\WP_Error Same as list_resources(). Errors are not cached.
197     */
198    public static function list_resources_cached( $page_size, $page_token = '' ) {
199        $version   = (int) get_option( self::LIST_CACHE_VERSION_OPTION, 0 );
200        $cache_key = 'paypal_list_cache_' . md5( $version . '|' . absint( $page_size ) . '|' . $page_token );
201        $result    = get_transient( $cache_key );
202
203        if ( false === $result ) {
204            $result = self::list_resources( $page_size, $page_token );
205
206            if ( ! is_wp_error( $result ) ) {
207                set_transient( $cache_key, $result, self::LIST_CACHE_TTL );
208            }
209        }
210
211        return $result;
212    }
213
214    /**
215     * Get a single payment resource, served from a short cache.
216     *
217     * Updating or deleting the resource through this class drops its entry.
218     *
219     * @since 0.9.0
220     *
221     * @param string $resource_id PayPal resource ID (format: PLB-XXXXXXXXXXXX).
222     * @return array|\WP_Error Same as get_resource(). Errors are not cached.
223     */
224    public static function get_resource_cached( $resource_id ) {
225        $cache_key = self::resource_cache_key( $resource_id );
226        $resource  = get_transient( $cache_key );
227
228        if ( false === $resource ) {
229            $resource = self::get_resource( $resource_id );
230
231            if ( ! is_wp_error( $resource ) ) {
232                set_transient( $cache_key, $resource, self::RESOURCE_CACHE_TTL );
233            }
234        }
235
236        return $resource;
237    }
238
239    /**
240     * Drop every cached list page, and one cached resource when named.
241     *
242     * @since 0.9.0
243     *
244     * @param string $resource_id A resource whose cached copy is stale too. Default none.
245     * @return void
246     */
247    public static function forget_cached_resources( $resource_id = '' ) {
248        $version = (int) get_option( self::LIST_CACHE_VERSION_OPTION, 0 );
249        update_option( self::LIST_CACHE_VERSION_OPTION, $version + 1, false );
250
251        if ( '' !== $resource_id ) {
252            delete_transient( self::resource_cache_key( $resource_id ) );
253        }
254    }
255
256    /**
257     * The transient that holds one cached resource.
258     *
259     * @param string $resource_id PayPal resource ID.
260     * @return string
261     */
262    private static function resource_cache_key( $resource_id ) {
263        return 'paypal_resource_' . sanitize_key( $resource_id );
264    }
265
266    /**
267     * Get a single payment resource by ID.
268     *
269     * @param string $resource_id PayPal resource ID (format: PLB-XXXXXXXXXXXX).
270     * @return array|\WP_Error Decoded response body on success (HTTP 200), WP_Error on failure.
271     */
272    public static function get_resource( $resource_id ) {
273        $resource_id = self::sanitize_resource_id( $resource_id );
274        if ( is_wp_error( $resource_id ) ) {
275            return $resource_id;
276        }
277
278        $result = self::make_request_with_retry(
279            'GET',
280            self::RESOURCES_ENDPOINT . '/' . $resource_id,
281            null,
282            200
283        );
284
285        if ( is_wp_error( $result ) ) {
286            return $result;
287        }
288
289        // Extract payment_link from HATEOAS links array to top-level field.
290        $result = self::extract_payment_link( $result );
291
292        // Validate payment_link domain if present.
293        if ( ! empty( $result['payment_link'] ) ) {
294            $validation = self::validate_paypal_url( $result['payment_link'] );
295            if ( is_wp_error( $validation ) ) {
296                return $validation;
297            }
298        }
299
300        return $result;
301    }
302
303    /**
304     * Update a payment resource (full replacement via PUT).
305     *
306     * PayPal answers a successful PUT with an empty 204, so this echoes the request
307     * back with the id. Call get_resource() for the payment's actual state, which a
308     * full replacement can move. 200 counts as success too, in case PayPal ever
309     * answers with a body; the body is discarded either way.
310     *
311     * @param string $resource_id   PayPal resource ID (format: PLB-XXXXXXXXXXXX).
312     * @param array  $resource_data Complete updated resource data (same schema as create).
313     * @return array|\WP_Error The data that was sent, plus the resource id, or WP_Error on failure.
314     */
315    public static function update_resource( $resource_id, $resource_data ) {
316        $resource_id = self::sanitize_resource_id( $resource_id );
317        if ( is_wp_error( $resource_id ) ) {
318            return $resource_id;
319        }
320
321        $result = self::make_request_with_retry(
322            'PUT',
323            self::RESOURCES_ENDPOINT . '/' . $resource_id,
324            $resource_data,
325            array( 204, 200 )
326        );
327
328        if ( is_wp_error( $result ) ) {
329            return $result;
330        }
331
332        self::forget_cached_resources( $resource_id );
333
334        return array_merge( $resource_data, array( 'id' => $resource_id ) );
335    }
336
337    /**
338     * Delete a payment resource.
339     *
340     * @param string $resource_id PayPal resource ID (format: PLB-XXXXXXXXXXXX).
341     * @return true|\WP_Error True on success (HTTP 204), WP_Error on failure.
342     */
343    public static function delete_resource( $resource_id ) {
344        $resource_id = self::sanitize_resource_id( $resource_id );
345        if ( is_wp_error( $resource_id ) ) {
346            return $resource_id;
347        }
348
349        $result = self::make_request_with_retry(
350            'DELETE',
351            self::RESOURCES_ENDPOINT . '/' . $resource_id,
352            null,
353            204
354        );
355
356        if ( is_wp_error( $result ) ) {
357            // Gone already: the cached copy is just as stale as after a delete.
358            $error_data = $result->get_error_data();
359            if ( isset( $error_data['status'] ) && 404 === (int) $error_data['status'] ) {
360                self::forget_cached_resources( $resource_id );
361                self::remember_deleted_resource( $resource_id );
362            }
363            return $result;
364        }
365
366        self::forget_cached_resources( $resource_id );
367        self::remember_deleted_resource( $resource_id );
368
369        return true;
370    }
371
372    /**
373     * Record a deleted link, so a published block still pointing at it renders nothing.
374     *
375     * @since 0.9.0
376     *
377     * @param string $resource_id PayPal resource ID.
378     * @return void
379     */
380    public static function remember_deleted_resource( $resource_id ) {
381        $deleted = self::deleted_resources();
382        array_unshift( $deleted, $resource_id );
383        $deleted = array_slice( array_values( array_unique( $deleted ) ), 0, self::DELETED_RESOURCES_LIMIT );
384
385        update_option( self::DELETED_RESOURCES_OPTION, $deleted, false );
386    }
387
388    /**
389     * Whether a link was deleted through this site.
390     *
391     * @since 0.9.0
392     *
393     * @param string $resource_id PayPal resource ID.
394     * @return bool
395     */
396    public static function is_deleted_resource( $resource_id ) {
397        return in_array( $resource_id, self::deleted_resources(), true );
398    }
399
400    /**
401     * The remembered deleted links, newest first.
402     *
403     * @return string[]
404     */
405    private static function deleted_resources() {
406        $deleted = get_option( self::DELETED_RESOURCES_OPTION, array() );
407
408        return is_array( $deleted ) ? $deleted : array();
409    }
410
411    /**
412     * Make a request with automatic retry logic.
413     *
414     * Handles two retry scenarios:
415     * 1. 401/403 errors: refresh the OAuth token and retry once.
416     * 2. 500/502/503 errors: retry with exponential backoff (up to MAX_RETRIES).
417     * 3. Network timeouts: retry with exponential backoff (up to MAX_RETRIES).
418     *
419     * @param string     $method          HTTP method (GET, POST, PUT, DELETE).
420     * @param string     $endpoint        API endpoint path.
421     * @param array|null $body            Request body data.
422     * @param int|array  $expected_status Status code, or codes, that count as success.
423     * @return array|null|\WP_Error Decoded response body, null for 204, or WP_Error.
424     */
425    private static function make_request_with_retry( $method, $endpoint, $body, $expected_status ) {
426        $last_error   = null;
427        $auth_retried = false;
428
429        // Generate a single request ID for all retry attempts to ensure idempotency.
430        $request_id = wp_generate_uuid4();
431
432        for ( $attempt = 0; $attempt <= self::MAX_RETRIES; $attempt++ ) {
433            $result = self::make_request( $method, $endpoint, $body, $expected_status, $request_id );
434
435            // Success â€” return immediately.
436            if ( ! is_wp_error( $result ) ) {
437                return $result;
438            }
439
440            $error_code = $result->get_error_code();
441            $error_data = $result->get_error_data();
442            $status     = isset( $error_data['status'] ) ? (int) $error_data['status'] : 0;
443
444            // Auth failure (401/403) â€” refresh token and retry exactly once. There is no
445            // site token to refresh when WordPress.com makes the call.
446            if ( in_array( $status, array( 401, 403 ), true ) && ! $auth_retried && ! PayPal_Partner_Onboarding::is_platform_managed() ) {
447                $auth_retried = true;
448                PayPal_OAuth::clear_cached_token();
449
450                // Verify we can still get a token before retrying.
451                $token = PayPal_OAuth::get_access_token();
452                if ( is_wp_error( $token ) ) {
453                    return $result; // Return original error â€” re-auth failed.
454                }
455
456                // Retry the request with the fresh token (don't increment attempt).
457                // Use a new request ID since this is a distinct attempt after re-auth.
458                $retry_result = self::make_request( $method, $endpoint, $body, $expected_status, wp_generate_uuid4() );
459                if ( ! is_wp_error( $retry_result ) ) {
460                    return $retry_result;
461                }
462
463                // If the retry also fails with 403, it's a permissions issue, not token expiry.
464                $retry_data   = $retry_result->get_error_data();
465                $retry_status = isset( $retry_data['status'] ) ? (int) $retry_data['status'] : 0;
466                if ( 403 === $retry_status ) {
467                    return new \WP_Error(
468                        'paypal_api_not_authorized',
469                        __( 'Your PayPal account is not authorized for Payment Links & Buttons. Please verify this feature is enabled in your PayPal Developer Dashboard.', 'jetpack-paypal-payments' ),
470                        array( 'status' => 403 )
471                    );
472                }
473
474                return $retry_result;
475            }
476
477            // Server error or network timeout â€” retry with backoff.
478            $is_server_error  = in_array( $status, array( 500, 502, 503 ), true );
479            $is_network_error = 'paypal_api_request_failed' === $error_code;
480            $is_timeout       = 'paypal_api_timeout' === $error_code;
481
482            if ( ( $is_server_error || $is_network_error || $is_timeout ) && $attempt < self::MAX_RETRIES ) {
483                $last_error = $result;
484                $delay      = self::BACKOFF_BASE_SECONDS * pow( 2, $attempt );
485                // phpcs:ignore WordPress.WP.AlternativeFunctions.sleep_usleep -- Intentional backoff delay.
486                usleep( (int) ( $delay * 1000000 ) );
487                continue;
488            }
489
490            // Non-retryable error (400, 404, 422, etc.) â€” return immediately.
491            return $result;
492        }
493
494        // All retries exhausted â€” return the last error.
495        if ( $last_error ) {
496            return $last_error;
497        }
498
499        return new \WP_Error(
500            'paypal_api_retry_exhausted',
501            __( 'PayPal is temporarily unavailable after multiple attempts. Please try again later.', 'jetpack-paypal-payments' ),
502            array( 'status' => 503 )
503        );
504    }
505
506    /**
507     * Make an authenticated request to the PayPal API.
508     *
509     * Handles token retrieval, header construction, response validation,
510     * and error mapping. Includes PayPal-Request-Id for idempotency.
511     *
512     * Note: PayPal-Partner-Attribution-Id is NOT supported on Payment Links
513     * API endpoints. BN code attribution is handled via the `at_code` query
514     * parameter on payment link URLs in the render layer.
515     *
516     * @param string     $method          HTTP method (GET, POST, PUT, DELETE).
517     * @param string     $endpoint        API endpoint path (appended to base URL).
518     * @param array|null $body            Request body data (JSON-encoded for POST/PUT).
519     * @param int|array  $expected_status Status code, or codes, that count as success.
520     * @param string     $request_id      Idempotency key.
521     * @return array|null|\WP_Error Decoded response body, null for 204, or WP_Error.
522     */
523    private static function make_request( $method, $endpoint, $body, $expected_status, $request_id ) {
524        $response = PayPal_Partner_Onboarding::is_platform_managed()
525            ? PayPal_Platform_Client::request( $method, $endpoint, $body, $request_id )
526            : self::make_direct_request( $method, $endpoint, $body, $request_id );
527
528        if ( is_wp_error( $response ) ) {
529            return $response;
530        }
531
532        $status_code = wp_remote_retrieve_response_code( $response );
533
534        // Success path.
535        if ( in_array( $status_code, (array) $expected_status, true ) ) {
536            // A 204 is empty. Ignore a body if PayPal ever sends one.
537            if ( 204 === $status_code ) {
538                return null;
539            }
540
541            $response_body = wp_remote_retrieve_body( $response );
542            $data          = json_decode( $response_body, true );
543
544            if ( null === $data && '' !== $response_body ) {
545                return new \WP_Error(
546                    'paypal_api_invalid_json',
547                    __( 'PayPal returned a response that could not be parsed as JSON.', 'jetpack-paypal-payments' ),
548                    array( 'status' => $status_code )
549                );
550            }
551
552            return $data;
553        }
554
555        // Error path â€” map PayPal error response to WP_Error.
556        return self::parse_error_response( $response, $status_code );
557    }
558
559    /**
560     * Call PayPal from the site, with the merchant's own credentials.
561     *
562     * @param string     $method     HTTP method (GET, POST, PUT, DELETE).
563     * @param string     $endpoint   API endpoint path (appended to base URL).
564     * @param array|null $body       Request body data (JSON-encoded for POST/PUT).
565     * @param string     $request_id Idempotency key.
566     * @return array|\WP_Error The wp_remote_request() response, or WP_Error when PayPal was unreachable.
567     */
568    private static function make_direct_request( $method, $endpoint, $body, $request_id ) {
569        $token = PayPal_OAuth::get_access_token();
570        if ( is_wp_error( $token ) ) {
571            return $token;
572        }
573
574        $url = PayPal_OAuth::get_base_url() . $endpoint;
575
576        $args = array(
577            'method'  => $method,
578            'timeout' => self::REQUEST_TIMEOUT,
579            'headers' => array(
580                'Authorization'     => 'Bearer ' . $token,
581                'Content-Type'      => 'application/json',
582                'Accept'            => 'application/json',
583                'PayPal-Request-Id' => $request_id,
584            ),
585        );
586
587        if ( null !== $body && in_array( $method, array( 'POST', 'PUT' ), true ) ) {
588            $args['body'] = wp_json_encode( $body, JSON_UNESCAPED_SLASHES );
589        }
590
591        $response = wp_remote_request( $url, $args );
592
593        if ( is_wp_error( $response ) ) {
594            $message = $response->get_error_message();
595
596            // Distinguish timeouts from other network errors for retry logic.
597            $is_timeout = false !== strpos( strtolower( $message ), 'timeout' )
598                || false !== strpos( strtolower( $message ), 'timed out' );
599
600            return new \WP_Error(
601                $is_timeout ? 'paypal_api_timeout' : 'paypal_api_request_failed',
602                $is_timeout
603                    ? __( 'The request to PayPal timed out. Please try again.', 'jetpack-paypal-payments' )
604                    : sprintf(
605                        /* translators: %s: error message from the HTTP request */
606                        __( 'PayPal API request failed: %s', 'jetpack-paypal-payments' ),
607                        $message
608                    ),
609                array( 'status' => 0 )
610            );
611        }
612
613        return $response;
614    }
615
616    /**
617     * Parse a PayPal error response into a WP_Error.
618     *
619     * Maps PayPal's standard error response format to descriptive WP_Error
620     * codes and messages. Never exposes raw API error details to merchants.
621     *
622     * @param array|\WP_Error $response    The wp_remote_request response.
623     * @param int             $status_code The HTTP status code.
624     * @return \WP_Error The parsed error.
625     */
626    private static function parse_error_response( $response, $status_code ) {
627        $body = wp_remote_retrieve_body( $response );
628        $data = json_decode( $body, true );
629
630        // PayPal error response shape: { name, message, details[] }
631        $error_name    = isset( $data['name'] ) ? sanitize_text_field( $data['name'] ) : 'UNKNOWN_ERROR';
632        $error_message = isset( $data['message'] ) ? sanitize_text_field( $data['message'] ) : '';
633        $error_details = isset( $data['details'] ) && is_array( $data['details'] ) ? $data['details'] : array();
634
635        // Build a human-readable message (never raw API text).
636        $message = self::get_user_friendly_message( $status_code, $error_name, $error_message, $error_details );
637
638        return new \WP_Error(
639            'paypal_api_' . strtolower( $error_name ),
640            $message,
641            array(
642                'status'      => $status_code,
643                'paypal_name' => $error_name,
644                'details'     => $error_details,
645            )
646        );
647    }
648
649    /**
650     * Get a user-friendly error message for a PayPal API error.
651     *
652     * Maps each HTTP status / error name to a clear, actionable message
653     * that a non-technical merchant can understand. Never surfaces raw
654     * API error strings.
655     *
656     * @param int    $status_code   HTTP status code.
657     * @param string $error_name    PayPal error name.
658     * @param string $error_message PayPal error message (used only for 400/422 detail).
659     * @param array  $error_details PayPal error details array.
660     * @return string Formatted error message.
661     */
662    private static function get_user_friendly_message( $status_code, $error_name, $error_message, $error_details = array() ) {
663        switch ( $status_code ) {
664            case 400:
665                // INVALID_REQUEST â€” try to extract field-level detail.
666                $field_errors = self::extract_field_errors( $error_details );
667                if ( ! empty( $field_errors ) ) {
668                    return sprintf(
669                        /* translators: %s: comma-separated list of field validation errors */
670                        __( 'Please fix the following: %s', 'jetpack-paypal-payments' ),
671                        implode( '; ', $field_errors )
672                    );
673                }
674                return __( 'The request contains invalid data. Please check your input and try again.', 'jetpack-paypal-payments' );
675
676            case 401:
677                // Token may have expired between cache and use.
678                PayPal_OAuth::clear_cached_token();
679                return __( 'PayPal authentication expired. Please try again.', 'jetpack-paypal-payments' );
680
681            case 403:
682                // NOT_AUTHORIZED â€” account-level issue.
683                return __( 'Your PayPal account is not authorized for Payment Links & Buttons. Please verify this feature is enabled in your PayPal Developer Dashboard.', 'jetpack-paypal-payments' );
684
685            case 404:
686                // RESOURCE_NOT_FOUND â€” stale button ID.
687                return __( 'This PayPal button no longer exists. It may have been deleted from PayPal. Please create a new button.', 'jetpack-paypal-payments' );
688
689            case 422:
690                // UNPROCESSABLE_ENTITY â€” business rule violation.
691                $field_errors = self::extract_field_errors( $error_details );
692                if ( ! empty( $field_errors ) ) {
693                    return sprintf(
694                        /* translators: %s: comma-separated list of validation errors */
695                        __( 'PayPal could not process your request: %s', 'jetpack-paypal-payments' ),
696                        implode( '; ', $field_errors )
697                    );
698                }
699                return __( 'PayPal could not process your request. Please check the amount and currency and try again.', 'jetpack-paypal-payments' );
700
701            case 429:
702                return __( 'Too many requests. Please wait a moment and try again.', 'jetpack-paypal-payments' );
703
704            case 500:
705            case 502:
706            case 503:
707                return __( 'PayPal is temporarily unavailable. Please try again in a few moments.', 'jetpack-paypal-payments' );
708
709            default:
710                return __( 'An unexpected error occurred while communicating with PayPal. Please try again.', 'jetpack-paypal-payments' );
711        }
712    }
713
714    /**
715     * Extract field-level error descriptions from PayPal error details.
716     *
717     * PayPal's detail objects have the shape:
718     *   { field: "/line_items/0/name", issue: "MISSING_REQUIRED_PARAMETER", description: "..." }
719     *
720     * We extract human-readable descriptions, sanitizing each one.
721     *
722     * @param array $details PayPal error details array.
723     * @return array List of sanitized error description strings.
724     */
725    private static function extract_field_errors( array $details ) {
726        $errors = array();
727
728        foreach ( $details as $detail ) {
729            if ( ! empty( $detail['description'] ) ) {
730                $errors[] = sanitize_text_field( $detail['description'] );
731            } elseif ( ! empty( $detail['issue'] ) ) {
732                // Fallback to issue name, made more readable.
733                $errors[] = str_replace( '_', ' ', strtolower( sanitize_text_field( $detail['issue'] ) ) );
734            }
735        }
736
737        return $errors;
738    }
739
740    /**
741     * Validate that a URL belongs to an allowed PayPal domain.
742     *
743     * Prevents accepting payment links from non-PayPal domains,
744     * which could indicate a compromised API response.
745     *
746     * @param string $url The URL to validate.
747     * @return true|\WP_Error True if valid, WP_Error if the domain is not allowed.
748     */
749    private static function validate_paypal_url( $url ) {
750        $parsed = wp_parse_url( $url );
751
752        if ( empty( $parsed['host'] ) ) {
753            return new \WP_Error(
754                'paypal_invalid_payment_link',
755                __( 'PayPal returned an invalid payment link URL.', 'jetpack-paypal-payments' ),
756                array( 'status' => 502 )
757            );
758        }
759
760        // Validate the scheme is HTTPS.
761        if ( empty( $parsed['scheme'] ) || 'https' !== strtolower( $parsed['scheme'] ) ) {
762            return new \WP_Error(
763                'paypal_insecure_payment_link',
764                __( 'PayPal returned a non-HTTPS payment link, which is not allowed.', 'jetpack-paypal-payments' ),
765                array( 'status' => 502 )
766            );
767        }
768
769        $host = strtolower( $parsed['host'] );
770
771        if ( ! in_array( $host, self::ALLOWED_PAYPAL_DOMAINS, true ) ) {
772            return new \WP_Error(
773                'paypal_untrusted_domain',
774                __( 'PayPal returned a payment link from an untrusted domain.', 'jetpack-paypal-payments' ),
775                array( 'status' => 502 )
776            );
777        }
778
779        return true;
780    }
781
782    /**
783     * Extract the payment link URL from a PayPal API response's links array.
784     *
785     * PayPal returns HATEOAS links as an array of objects with rel/href/method.
786     * The payment link has rel="payment_link". This method finds it and promotes
787     * it to a top-level `payment_link` field on the response array.
788     *
789     * @param array $result The decoded PayPal API response.
790     * @return array The response with `payment_link` added as a top-level field.
791     */
792    private static function extract_payment_link( $result ) {
793        if ( ! empty( $result['links'] ) && is_array( $result['links'] ) ) {
794            foreach ( $result['links'] as $link ) {
795                if ( isset( $link['rel'] ) && 'payment_link' === $link['rel'] && ! empty( $link['href'] ) ) {
796                    $result['payment_link'] = $link['href'];
797                    break;
798                }
799            }
800        }
801
802        return $result;
803    }
804
805    /**
806     * Sanitize and validate a PayPal resource ID.
807     *
808     * Expected format: PLB-XXXXXXXXXXXX (alphanumeric after PLB- prefix).
809     *
810     * @param string $resource_id The resource ID to validate.
811     * @return string|\WP_Error The sanitized ID, or WP_Error if invalid.
812     */
813    private static function sanitize_resource_id( $resource_id ) {
814        $resource_id = sanitize_text_field( $resource_id );
815
816        if ( empty( $resource_id ) ) {
817            return new \WP_Error(
818                'paypal_invalid_resource_id',
819                __( 'PayPal resource ID is required.', 'jetpack-paypal-payments' )
820            );
821        }
822
823        // Validate format: PLB- followed by alphanumeric characters.
824        if ( ! preg_match( '/^PLB-[A-Z0-9]+$/i', $resource_id ) ) {
825            return new \WP_Error(
826                'paypal_invalid_resource_id',
827                sprintf(
828                    /* translators: %s: the invalid resource ID */
829                    __( 'Invalid PayPal resource ID format: %s. Expected format: PLB-XXXXXXXXXXXX.', 'jetpack-paypal-payments' ),
830                    $resource_id
831                )
832            );
833        }
834
835        return $resource_id;
836    }
837}