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