Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.74% covered (success)
97.74%
865 / 885
71.43% covered (warning)
71.43%
20 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_REST_Controller
97.96% covered (success)
97.96%
865 / 883
71.43% covered (warning)
71.43%
20 / 28
141
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
248 / 248
100.00% covered (success)
100.00%
1 / 1
1
 manage_options_permission_check
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 sanitize_oauth_value
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validate_non_empty_string
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 handle_connect
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 connect_with_credentials
82.00% covered (warning)
82.00%
41 / 50
0.00% covered (danger)
0.00%
0 / 1
8.37
 handle_connection_status
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 handle_disconnect
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 handle_set_environment
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 handle_generate_signup_link
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 handle_onboarding_complete
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 handle_merchant_status
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 handle_create_button
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 handle_list_buttons
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 handle_get_button
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 get_sdk_url
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 handle_update_button
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
6.01
 handle_delete_button
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
1 / 1
6
 validate_button_request
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
12
 get_variant_currency
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 record_connection
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 get_button_event_properties
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 get_button_create_args
100.00% covered (success)
100.00%
165 / 165
100.00% covered (success)
100.00%
1 / 1
1
 build_resource_data
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 sanitize_line_items
97.22% covered (success)
97.22%
70 / 72
0.00% covered (danger)
0.00%
0 / 1
36
 sanitize_amount_list
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
8.04
 sanitize_variants
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
16.06
 api_error_to_rest_error
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * REST API controller for PayPal Payment Buttons.
4 *
5 * Provides endpoints for PayPal OAuth connection management
6 * and PayPal Pay Links & Buttons API operations.
7 *
8 * Updated for WOOPTP-151: Server-side validation via PayPal_Attribute_Mapper
9 * before all API calls, enhanced error responses, and 404 stale resource handling.
10 *
11 * @package automattic/jetpack-paypal-payments
12 * @since 0.7.0
13 */
14
15namespace Automattic\Jetpack\PaypalPayments;
16
17if ( ! defined( 'ABSPATH' ) ) {
18    exit;
19}
20
21use WP_Error;
22use WP_REST_Request;
23use WP_REST_Response;
24use WP_REST_Server;
25
26/**
27 * Class PayPal_REST_Controller
28 *
29 * Registers and handles WordPress REST API endpoints for
30 * PayPal OAuth connection and button management.
31 */
32class PayPal_REST_Controller {
33
34    /**
35     * REST API namespace.
36     *
37     * Deliberately `wpcom/v2` rather than `jetpack/v4`: public-api.wordpress.com only
38     * routes `wpcom/v2` for WordPress.com Simple sites, so `jetpack/v4` routes register
39     * fine but 404 at the proxy. `wpcom/v2` is served everywhere -- by WordPress.com for
40     * Simple sites, and by the Jetpack plugin itself on Atomic and self-hosted (see
41     * plugins/jetpack/_inc/lib/core-api/load-wpcom-endpoints.php). Same as the Donations
42     * block's `wpcom/v2/memberships/*` routes.
43     *
44     * @var string
45     */
46    const REST_NAMESPACE = 'wpcom/v2';
47
48    /**
49     * REST API route base for PayPal operations.
50     *
51     * @var string
52     */
53    const ROUTE_BASE = '/paypal';
54
55    /**
56     * Register REST API routes.
57     *
58     * @return void
59     */
60    public static function register_routes() {
61        // Connection management (manual credentials — fallback/advanced mode).
62        register_rest_route(
63            self::REST_NAMESPACE,
64            self::ROUTE_BASE . '/connect',
65            array(
66                array(
67                    'methods'             => WP_REST_Server::CREATABLE,
68                    'callback'            => array( __CLASS__, 'handle_connect' ),
69                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
70                    'args'                => array(
71                        'client_id'     => array(
72                            'required'          => true,
73                            'type'              => 'string',
74                            'sanitize_callback' => array( __CLASS__, 'sanitize_oauth_value' ),
75                            'validate_callback' => array( __CLASS__, 'validate_non_empty_string' ),
76                            'description'       => __( 'PayPal OAuth client ID.', 'jetpack-paypal-payments' ),
77                        ),
78                        'client_secret' => array(
79                            'required'          => true,
80                            'type'              => 'string',
81                            'sanitize_callback' => array( __CLASS__, 'sanitize_oauth_value' ),
82                            'validate_callback' => array( __CLASS__, 'validate_non_empty_string' ),
83                            'description'       => __( 'PayPal OAuth client secret.', 'jetpack-paypal-payments' ),
84                        ),
85                        'environment'   => array(
86                            'required'          => false,
87                            'type'              => 'string',
88                            'default'           => 'production',
89                            'enum'              => array( 'sandbox', 'production' ),
90                            'sanitize_callback' => 'sanitize_text_field',
91                            'description'       => __( 'PayPal environment: sandbox or production.', 'jetpack-paypal-payments' ),
92                        ),
93                    ),
94                ),
95            )
96        );
97
98        // Partner Referrals onboarding — generate signup link.
99        register_rest_route(
100            self::REST_NAMESPACE,
101            self::ROUTE_BASE . '/onboarding/signup-link',
102            array(
103                array(
104                    'methods'             => WP_REST_Server::CREATABLE,
105                    'callback'            => array( __CLASS__, 'handle_generate_signup_link' ),
106                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
107                    'args'                => array(
108                        'return_url'  => array(
109                            'required'          => true,
110                            'type'              => 'string',
111                            'format'            => 'uri',
112                            'sanitize_callback' => 'esc_url_raw',
113                            'description'       => __( 'URL PayPal redirects to after onboarding.', 'jetpack-paypal-payments' ),
114                        ),
115                        'environment' => array(
116                            'required'          => false,
117                            'type'              => 'string',
118                            'default'           => 'production',
119                            'enum'              => array( 'sandbox', 'production' ),
120                            'sanitize_callback' => 'sanitize_text_field',
121                            'description'       => __( 'PayPal environment: sandbox or production.', 'jetpack-paypal-payments' ),
122                        ),
123                    ),
124                ),
125            )
126        );
127
128        // Partner Referrals onboarding — complete (record the seller PayPal just onboarded).
129        register_rest_route(
130            self::REST_NAMESPACE,
131            self::ROUTE_BASE . '/onboarding/complete',
132            array(
133                array(
134                    'methods'             => WP_REST_Server::CREATABLE,
135                    'callback'            => array( __CLASS__, 'handle_onboarding_complete' ),
136                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
137                    'args'                => array(
138                        'merchant_id_in_paypal' => array(
139                            'required'          => false,
140                            'type'              => 'string',
141                            'default'           => '',
142                            'sanitize_callback' => 'sanitize_text_field',
143                            'description'       => __( 'Merchant PayPal payer ID from onboarding callback.', 'jetpack-paypal-payments' ),
144                        ),
145                        'quiet'                 => array(
146                            'required'    => false,
147                            'type'        => 'boolean',
148                            'default'     => false,
149                            'description' => __( 'Whether this is the check made when the merchant closes PayPal, where no seller yet means they cancelled.', 'jetpack-paypal-payments' ),
150                        ),
151                    ),
152                ),
153            )
154        );
155
156        // Partner Referrals onboarding — check merchant status.
157        register_rest_route(
158            self::REST_NAMESPACE,
159            self::ROUTE_BASE . '/onboarding/status',
160            array(
161                array(
162                    'methods'             => WP_REST_Server::READABLE,
163                    'callback'            => array( __CLASS__, 'handle_merchant_status' ),
164                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
165                ),
166            )
167        );
168
169        // Connection status.
170        register_rest_route(
171            self::REST_NAMESPACE,
172            self::ROUTE_BASE . '/connection',
173            array(
174                array(
175                    'methods'             => WP_REST_Server::READABLE,
176                    'callback'            => array( __CLASS__, 'handle_connection_status' ),
177                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
178                ),
179            )
180        );
181
182        // Disconnect.
183        register_rest_route(
184            self::REST_NAMESPACE,
185            self::ROUTE_BASE . '/disconnect',
186            array(
187                array(
188                    'methods'             => WP_REST_Server::CREATABLE,
189                    'callback'            => array( __CLASS__, 'handle_disconnect' ),
190                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
191                ),
192            )
193        );
194
195        // --- Button CRUD endpoints ---
196
197        // Create a payment resource (button/link).
198        register_rest_route(
199            self::REST_NAMESPACE,
200            self::ROUTE_BASE . '/buttons',
201            array(
202                array(
203                    'methods'             => WP_REST_Server::CREATABLE,
204                    'callback'            => array( __CLASS__, 'handle_create_button' ),
205                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
206                    'args'                => array_merge(
207                        self::get_button_create_args(),
208                        array(
209                            'recreated' => array(
210                                'required'    => false,
211                                'type'        => 'boolean',
212                                'default'     => false,
213                                'description' => __( 'Whether this replaces a payment button PayPal no longer has.', 'jetpack-paypal-payments' ),
214                            ),
215                        )
216                    ),
217                ),
218            )
219        );
220
221        // List payment resources.
222        register_rest_route(
223            self::REST_NAMESPACE,
224            self::ROUTE_BASE . '/buttons',
225            array(
226                array(
227                    'methods'             => WP_REST_Server::READABLE,
228                    'callback'            => array( __CLASS__, 'handle_list_buttons' ),
229                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
230                    'args'                => array(
231                        'page_size'  => array(
232                            'required'    => false,
233                            'type'        => 'integer',
234                            // PayPal has no server-side search, so callers filter
235                            // client-side. Fetch a whole page by default.
236                            'default'     => 100,
237                            'minimum'     => 1,
238                            'maximum'     => 100,
239                            'description' => __( 'Payment resources per page.', 'jetpack-paypal-payments' ),
240                        ),
241                        'page_token' => array(
242                            'required'          => false,
243                            'type'              => 'string',
244                            'default'           => '',
245                            'sanitize_callback' => 'sanitize_text_field',
246                            'description'       => __( 'Cursor from the previous page\'s next link.', 'jetpack-paypal-payments' ),
247                        ),
248                    ),
249                ),
250            )
251        );
252
253        // Get a single payment resource.
254        register_rest_route(
255            self::REST_NAMESPACE,
256            self::ROUTE_BASE . '/buttons/(?P<resource_id>PLB-[A-Za-z0-9]+)',
257            array(
258                array(
259                    'methods'             => WP_REST_Server::READABLE,
260                    'callback'            => array( __CLASS__, 'handle_get_button' ),
261                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
262                ),
263            )
264        );
265
266        // Update a payment resource (full replacement via PUT).
267        register_rest_route(
268            self::REST_NAMESPACE,
269            self::ROUTE_BASE . '/buttons/(?P<resource_id>PLB-[A-Za-z0-9]+)',
270            array(
271                array(
272                    'methods'             => 'PUT',
273                    'callback'            => array( __CLASS__, 'handle_update_button' ),
274                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
275                    'args'                => array_merge(
276                        self::get_button_create_args(),
277                        array(
278                            'include_snippets' => array(
279                                'required'    => false,
280                                'type'        => 'boolean',
281                                'default'     => false,
282                                'description' => __( 'Read the payment back after updating it, for the SDK snippets a stacked block needs.', 'jetpack-paypal-payments' ),
283                            ),
284                        )
285                    ),
286                ),
287            )
288        );
289
290        // Delete a payment resource.
291        register_rest_route(
292            self::REST_NAMESPACE,
293            self::ROUTE_BASE . '/buttons/(?P<resource_id>PLB-[A-Za-z0-9]+)',
294            array(
295                array(
296                    'methods'             => WP_REST_Server::DELETABLE,
297                    'callback'            => array( __CLASS__, 'handle_delete_button' ),
298                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
299                    'args'                => array(
300                        'unused_only' => array(
301                            'type'        => 'boolean',
302                            'default'     => false,
303                            'description' => __( 'Keep the payment button when another published post still embeds it.', 'jetpack-paypal-payments' ),
304                        ),
305                        'post_id'     => array(
306                            'type'        => 'integer',
307                            'default'     => 0,
308                            'description' => __( 'One post to leave out of the embed count.', 'jetpack-paypal-payments' ),
309                        ),
310                    ),
311                ),
312            )
313        );
314
315        // Environment switch.
316        register_rest_route(
317            self::REST_NAMESPACE,
318            self::ROUTE_BASE . '/environment',
319            array(
320                array(
321                    'methods'             => WP_REST_Server::CREATABLE,
322                    'callback'            => array( __CLASS__, 'handle_set_environment' ),
323                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
324                    'args'                => array(
325                        'environment' => array(
326                            'required'          => true,
327                            'type'              => 'string',
328                            'enum'              => array( 'sandbox', 'production' ),
329                            'sanitize_callback' => 'sanitize_text_field',
330                            'description'       => __( 'PayPal environment: sandbox or production.', 'jetpack-paypal-payments' ),
331                        ),
332                    ),
333                ),
334            )
335        );
336    }
337
338    /**
339     * Permission check: current user can manage_options.
340     *
341     * @return bool|WP_Error True if permitted, WP_Error otherwise.
342     */
343    public static function manage_options_permission_check() {
344        if ( ! current_user_can( 'manage_options' ) ) {
345            return new WP_Error(
346                'rest_forbidden',
347                __( 'You do not have permission to manage PayPal settings.', 'jetpack-paypal-payments' ),
348                array( 'status' => 403 )
349            );
350        }
351
352        return true;
353    }
354
355    /**
356     * Sanitize an OAuth credential or token value.
357     *
358     * Uses trim() instead of sanitize_text_field() to preserve valid OAuth
359     * characters (+, /, =) that sanitize_text_field() would strip. These
360     * values are never rendered to HTML — they go into encrypted storage
361     * and HTTP Authorization headers.
362     *
363     * @param string $value The value to sanitize.
364     * @return string The trimmed value.
365     */
366    public static function sanitize_oauth_value( $value ) {
367        return trim( wp_unslash( $value ) );
368    }
369
370    /**
371     * Validate that a string parameter is non-empty.
372     *
373     * Used as a REST validate_callback, so the incoming value may be of any type;
374     * anything that is not a non-empty string is rejected.
375     *
376     * @param mixed           $value   The value to validate.
377     * @param WP_REST_Request $request The REST request.
378     * @param string          $param   The parameter name.
379     * @return bool|WP_Error True if valid, WP_Error otherwise.
380     */
381    public static function validate_non_empty_string( $value, $request, $param ) {
382        if ( ! is_string( $value ) || '' === trim( $value ) ) {
383            return new WP_Error(
384                'rest_invalid_param',
385                sprintf(
386                    /* translators: %s: parameter name */
387                    __( 'The %s parameter must be a non-empty string.', 'jetpack-paypal-payments' ),
388                    $param
389                ),
390                array( 'status' => 400 )
391            );
392        }
393
394        return true;
395    }
396
397    /**
398     * Handle POST /paypal/connect -- store credentials and validate via token exchange.
399     *
400     * @param WP_REST_Request $request The REST request.
401     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
402     */
403    public static function handle_connect( WP_REST_Request $request ) {
404        $result = self::connect_with_credentials( $request );
405
406        self::record_connection( $result, $request->get_param( 'environment' ), 'manual' );
407
408        return $result;
409    }
410
411    /**
412     * Store the credentials from a connect request and check them with PayPal.
413     *
414     * @since 0.12.0
415     *
416     * @param WP_REST_Request $request The REST request.
417     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
418     */
419    private static function connect_with_credentials( WP_REST_Request $request ) {
420        $client_id     = $request->get_param( 'client_id' );
421        $client_secret = $request->get_param( 'client_secret' );
422        $environment   = $request->get_param( 'environment' );
423
424        // Save previous environment so we can restore it if connect fails.
425        $previous_environment = PayPal_OAuth::get_environment();
426
427        // Set environment first so token exchange uses the right base URL.
428        PayPal_OAuth::set_environment( $environment );
429
430        // Store the credentials.
431        $stored = PayPal_OAuth::store_credentials( $client_id, $client_secret );
432        if ( is_wp_error( $stored ) ) {
433            PayPal_OAuth::set_environment( $previous_environment );
434            return self::api_error_to_rest_error( $stored );
435        }
436        if ( ! $stored ) {
437            PayPal_OAuth::set_environment( $previous_environment );
438            return new WP_Error(
439                'paypal_credentials_storage_failed',
440                __( 'Failed to store PayPal credentials. Please ensure your WordPress installation supports encryption (OpenSSL extension).', 'jetpack-paypal-payments' ),
441                array( 'status' => 500 )
442            );
443        }
444
445        // Validate by attempting a token exchange.
446        $validation = PayPal_OAuth::validate_credentials();
447        if ( is_wp_error( $validation ) ) {
448            // Credentials are invalid — remove them and restore previous environment.
449            PayPal_OAuth::delete_credentials();
450            PayPal_OAuth::set_environment( $previous_environment );
451
452            // Provide a user-friendly message based on the error type.
453            $error_data = $validation->get_error_data();
454            $status     = isset( $error_data['status'] ) ? (int) $error_data['status'] : 401;
455
456            if ( 401 === $status ) {
457                $message = __( 'The Client ID or Client Secret is incorrect. Please double-check your credentials in the PayPal Developer Dashboard.', 'jetpack-paypal-payments' );
458            } else {
459                $message = __( 'Could not connect to PayPal. Please check your credentials and try again.', 'jetpack-paypal-payments' );
460            }
461
462            return new WP_Error(
463                'paypal_credentials_invalid',
464                $message,
465                array( 'status' => $status )
466            );
467        }
468
469        // Validate that the account has Payment Links & Buttons API access.
470        $api_access = PayPal_OAuth::validate_api_access();
471        if ( is_wp_error( $api_access ) ) {
472            // Credentials are valid but the app lacks the required scope — remove them and restore previous environment.
473            PayPal_OAuth::delete_credentials();
474            PayPal_OAuth::set_environment( $previous_environment );
475
476            $error_data = $api_access->get_error_data();
477            $status     = isset( $error_data['status'] ) ? (int) $error_data['status'] : 403;
478
479            return new WP_Error(
480                $api_access->get_error_code(),
481                $api_access->get_error_message(),
482                array( 'status' => $status )
483            );
484        }
485
486        // These credentials replace any seller referred earlier.
487        PayPal_Partner_Onboarding::cleanup();
488
489        return new WP_REST_Response(
490            array(
491                'connected'   => true,
492                'environment' => PayPal_OAuth::get_environment(),
493                'message'     => __( 'PayPal account connected successfully.', 'jetpack-paypal-payments' ),
494            ),
495            200
496        );
497    }
498
499    /**
500     * Handle GET /paypal/connection -- return current connection status.
501     *
502     * @param WP_REST_Request $request The REST request.
503     * @return WP_REST_Response Response with connection status.
504     */
505    public static function handle_connection_status( WP_REST_Request $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
506        $status = PayPal_OAuth::get_connection_status();
507
508        // The editor appends this to payment links it copies to the clipboard,
509        // so those links are attributed the same way the rendered button is.
510        $status['partner_attribution_id'] = PayPal_Payment_Buttons::get_partner_attribution_id();
511
512        return new WP_REST_Response( $status, 200 );
513    }
514
515    /**
516     * Handle POST /paypal/disconnect -- remove credentials and cached token.
517     *
518     * @param WP_REST_Request $request The REST request.
519     * @return WP_REST_Response Response confirming disconnection.
520     */
521    public static function handle_disconnect( WP_REST_Request $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
522        // Read these before disconnect() deletes them.
523        $was_connected = PayPal_OAuth::is_connected();
524        $environment   = PayPal_OAuth::get_environment();
525
526        PayPal_OAuth::disconnect();
527        PayPal_Partner_Onboarding::cleanup();
528
529        // Record only when PayPal was connected, which skips a repeat POST from a stale tab.
530        if ( $was_connected ) {
531            PayPal_Tracks::record_event( 'jetpack_paypal_disconnected', array( 'environment' => $environment ) );
532        }
533
534        return new WP_REST_Response(
535            array(
536                'connected' => false,
537                'message'   => __( 'PayPal account disconnected.', 'jetpack-paypal-payments' ),
538            ),
539            200
540        );
541    }
542
543    /**
544     * Handle POST /paypal/environment -- switch between sandbox and production.
545     *
546     * @param WP_REST_Request $request The REST request.
547     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
548     */
549    public static function handle_set_environment( WP_REST_Request $request ) {
550        $environment = $request->get_param( 'environment' );
551
552        PayPal_OAuth::set_environment( $environment );
553
554        return new WP_REST_Response(
555            array(
556                'environment' => PayPal_OAuth::get_environment(),
557                'message'     => sprintf(
558                    /* translators: %s: environment name (sandbox or production) */
559                    __( 'PayPal environment set to %s. Cached token has been cleared.', 'jetpack-paypal-payments' ),
560                    $environment
561                ),
562            ),
563            200
564        );
565    }
566
567    // --- Partner Referrals onboarding handlers ---
568
569    /**
570     * Handle POST /paypal/onboarding/signup-link -- generate a Partner Referrals signup URL.
571     *
572     * @param WP_REST_Request $request The REST request.
573     * @return WP_REST_Response|WP_Error Response with action_url on success.
574     */
575    public static function handle_generate_signup_link( WP_REST_Request $request ) {
576        $return_url  = $request->get_param( 'return_url' );
577        $environment = $request->get_param( 'environment' );
578
579        // Set environment so the referral uses the right PayPal base URL.
580        PayPal_OAuth::set_environment( $environment );
581
582        $result = PayPal_Partner_Onboarding::generate_signup_link( $return_url, $environment );
583
584        if ( is_wp_error( $result ) ) {
585            return self::api_error_to_rest_error( $result );
586        }
587
588        return new WP_REST_Response(
589            array(
590                'action_url'  => $result['action_url'],
591                'referral_id' => $result['referral_id'],
592                'environment' => $environment,
593            ),
594            200
595        );
596    }
597
598    /**
599     * Handle POST /paypal/onboarding/complete -- record the seller PayPal just onboarded.
600     *
601     * Called by the block editor after the merchant completes the PayPal
602     * mini-browser onboarding flow.
603     *
604     * @param WP_REST_Request $request The REST request.
605     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
606     */
607    public static function handle_onboarding_complete( WP_REST_Request $request ) {
608        $result = PayPal_Partner_Onboarding::complete_onboarding(
609            (string) $request->get_param( 'merchant_id_in_paypal' )
610        );
611
612        // No seller yet on the close check is a cancel, not a failed connect.
613        $cancelled = $request->get_param( 'quiet' )
614            && is_wp_error( $result )
615            && in_array( $result->get_error_code(), array( 'paypal_merchant_not_found', 'paypal_onboarding_no_session' ), true );
616
617        if ( ! $cancelled ) {
618            self::record_connection( $result, PayPal_OAuth::get_environment(), 'partner_referrals' );
619        }
620
621        if ( is_wp_error( $result ) ) {
622            return self::api_error_to_rest_error( $result );
623        }
624
625        return new WP_REST_Response(
626            array(
627                'connected'     => true,
628                'environment'   => PayPal_OAuth::get_environment(),
629                'merchant_id'   => PayPal_Partner_Onboarding::get_merchant_id(),
630                'account_email' => PayPal_Partner_Onboarding::get_merchant_email(),
631                'method'        => 'partner_referrals',
632                'message'       => __( 'PayPal account connected successfully via Connect with PayPal.', 'jetpack-paypal-payments' ),
633            ),
634            200
635        );
636    }
637
638    /**
639     * Handle GET /paypal/onboarding/status -- check merchant integration status.
640     *
641     * @param WP_REST_Request $request The REST request.
642     * @return WP_REST_Response|WP_Error Response with merchant status.
643     */
644    public static function handle_merchant_status( WP_REST_Request $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
645        $status = PayPal_Partner_Onboarding::check_merchant_status();
646
647        if ( is_wp_error( $status ) ) {
648            return self::api_error_to_rest_error( $status );
649        }
650
651        return new WP_REST_Response( $status, 200 );
652    }
653
654    // --- Button CRUD handlers ---
655
656    /**
657     * Handle POST /paypal/buttons -- create a payment resource via the PayPal API.
658     *
659     * Validates input via PayPal_Attribute_Mapper before calling the API.
660     *
661     * @param WP_REST_Request $request The REST request.
662     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
663     */
664    public static function handle_create_button( WP_REST_Request $request ) {
665        // Server-side validation before API call.
666        $validation = self::validate_button_request( $request );
667        if ( is_wp_error( $validation ) ) {
668            return $validation;
669        }
670
671        $resource_data = self::build_resource_data( $request );
672
673        $result = PayPal_API_Client::create_resource( $resource_data );
674
675        if ( is_wp_error( $result ) ) {
676            return self::api_error_to_rest_error( $result );
677        }
678
679        // The 201 includes code_snippets, so map it here too: a new stacked block would
680        // otherwise save an empty scriptSrc, and the mount GET comes too late to fix it.
681        $result['attributes'] = PayPal_Attribute_Mapper::api_response_to_attributes( $result );
682
683        // Replacements are tracked as recreated, so "created" counts only new links.
684        PayPal_Tracks::record_event(
685            $request->get_param( 'recreated' ) ? 'jetpack_paypal_button_recreated' : 'jetpack_paypal_button_created',
686            self::get_button_event_properties( $resource_data )
687        );
688
689        return new WP_REST_Response( $result, 201 );
690    }
691
692    /**
693     * Handle GET /paypal/buttons -- list payment resources.
694     *
695     * @param WP_REST_Request $request The REST request.
696     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
697     */
698    public static function handle_list_buttons( WP_REST_Request $request ) {
699        $page_size  = $request->get_param( 'page_size' );
700        $page_token = $request->get_param( 'page_token' );
701
702        $result = PayPal_API_Client::list_resources( $page_size, $page_token );
703
704        if ( is_wp_error( $result ) ) {
705            return self::api_error_to_rest_error( $result );
706        }
707
708        return new WP_REST_Response( $result, 200 );
709    }
710
711    /**
712     * Handle GET /paypal/buttons/{resource_id} -- get a single payment resource.
713     *
714     * @param WP_REST_Request $request The REST request.
715     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
716     */
717    public static function handle_get_button( WP_REST_Request $request ) {
718        $resource_id = $request->get_param( 'resource_id' );
719
720        $result = PayPal_API_Client::get_resource( $resource_id );
721
722        if ( is_wp_error( $result ) ) {
723            return self::api_error_to_rest_error( $result );
724        }
725
726        // The editor reads a payment back to line its block up with what PayPal
727        // holds, so hand it the block shape alongside the raw resource, and the
728        // published posts embedding it for the link details view.
729        $result['attributes'] = PayPal_Attribute_Mapper::api_response_to_attributes( $result );
730        $result['embeds']     = PayPal_Admin_Page::count_published_embeds()[ $resource_id ] ?? 0;
731
732        // The editor's stacked preview loads PayPal's SDK from this until the block has a scriptSrc.
733        $result['sdk_url'] = self::get_sdk_url( $result['attributes']['currencyCode'] ?? 'USD' );
734
735        return new WP_REST_Response( $result, 200 );
736    }
737
738    /**
739     * Build the PayPal SDK URL for the connected account, with the same parameters as
740     * PayPal's stacked buttons snippet.
741     *
742     * @param string $currency The payment's currency.
743     * @return string The URL, or '' when PayPal is disconnected.
744     */
745    private static function get_sdk_url( $currency ) {
746        // add_query_arg() leaves values as they are, and a client id can contain + / =.
747        if ( PayPal_Partner_Onboarding::is_platform_managed() ) {
748            // A referred seller's buttons load under the platform's client ID, on the seller's account.
749            $partner_client_id = PayPal_Partner_Onboarding::get_partner_client_id();
750            if ( '' === $partner_client_id ) {
751                return '';
752            }
753            $account = array(
754                'client-id'   => rawurlencode( $partner_client_id ),
755                'merchant-id' => rawurlencode( PayPal_Partner_Onboarding::get_merchant_id() ),
756            );
757        } else {
758            $credentials = PayPal_OAuth::get_credentials();
759            if ( false === $credentials ) {
760                return '';
761            }
762            $account = array( 'client-id' => rawurlencode( $credentials['client_id'] ) );
763        }
764
765        return add_query_arg(
766            $account + array(
767                'components'     => 'hosted-buttons',
768                'enable-funding' => 'venmo',
769                'currency'       => rawurlencode( $currency ),
770            ),
771            PayPal_OAuth::get_sdk_base_url()
772        );
773    }
774
775    /**
776     * Handle PUT /paypal/buttons/{resource_id} -- update a payment resource (full replacement).
777     *
778     * Validates input via PayPal_Attribute_Mapper before calling the API.
779     *
780     * @param WP_REST_Request $request The REST request.
781     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
782     */
783    public static function handle_update_button( WP_REST_Request $request ) {
784        // Server-side validation before API call.
785        $validation = self::validate_button_request( $request );
786        if ( is_wp_error( $validation ) ) {
787            return $validation;
788        }
789
790        $resource_id   = $request->get_param( 'resource_id' );
791        $resource_data = self::build_resource_data( $request );
792
793        // A PUT replaces the whole payment, and a link made through PayPal's API can hold
794        // keys the block has no control for, so read it first and carry those over.
795        $current = PayPal_API_Client::get_resource( $resource_id );
796        if ( is_wp_error( $current ) ) {
797            return self::api_error_to_rest_error( $current );
798        }
799        $resource_data = PayPal_Attribute_Mapper::merge_into_resource( $current, $resource_data );
800
801        $result = PayPal_API_Client::update_resource( $resource_id, $resource_data );
802
803        if ( is_wp_error( $result ) ) {
804            return self::api_error_to_rest_error( $result );
805        }
806
807        // PayPal answers a PUT with an empty 204, so a stacked block's save asks for a re-read
808        // to get its SDK URL in the same save. Only the re-read gets `attributes`. The echo has
809        // no code_snippets, so mapping it would blank a stacked block's scriptSrc.
810        if ( $request->get_param( 'include_snippets' ) ) {
811            $fresh = PayPal_API_Client::get_resource( $resource_id );
812            if ( ! is_wp_error( $fresh ) ) {
813                $result               = $fresh;
814                $result['attributes'] = PayPal_Attribute_Mapper::api_response_to_attributes( $result );
815            }
816        }
817
818        return new WP_REST_Response( $result, 200 );
819    }
820
821    /**
822     * Handle DELETE /paypal/buttons/{resource_id} -- delete a payment resource.
823     *
824     * @param WP_REST_Request $request The REST request.
825     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
826     */
827    public static function handle_delete_button( WP_REST_Request $request ) {
828        $resource_id = $request->get_param( 'resource_id' );
829
830        // Spare a link another published post still embeds.
831        if ( $request->get_param( 'unused_only' ) ) {
832            $embeds = PayPal_Admin_Page::count_published_embeds( absint( $request->get_param( 'post_id' ) ) );
833            $others = $embeds[ $resource_id ] ?? 0;
834            if ( $others > 0 ) {
835                return new WP_REST_Response(
836                    array(
837                        'deleted'     => false,
838                        'resource_id' => $resource_id,
839                        'message'     => sprintf(
840                            /* translators: %d: number of published posts */
841                            _n(
842                                'Kept the payment button: %d other published post still uses it.',
843                                'Kept the payment button: %d other published posts still use it.',
844                                $others,
845                                'jetpack-paypal-payments'
846                            ),
847                            $others
848                        ),
849                    ),
850                    200
851                );
852            }
853        }
854
855        $result = PayPal_API_Client::delete_resource( $resource_id );
856
857        if ( is_wp_error( $result ) ) {
858            // If the resource is already gone (404), treat as success.
859            $error_data = $result->get_error_data();
860            if ( isset( $error_data['status'] ) && 404 === (int) $error_data['status'] ) {
861                PayPal_Tracks::record_event(
862                    'jetpack_paypal_button_deleted',
863                    array(
864                        'environment'  => PayPal_OAuth::get_environment(),
865                        'source'       => 'editor',
866                        'already_gone' => true,
867                    )
868                );
869
870                return new WP_REST_Response(
871                    array(
872                        'deleted'     => true,
873                        'resource_id' => $resource_id,
874                        'message'     => __( 'Payment resource was already deleted from PayPal.', 'jetpack-paypal-payments' ),
875                    ),
876                    200
877                );
878            }
879
880            return self::api_error_to_rest_error( $result );
881        }
882
883        PayPal_Tracks::record_event(
884            'jetpack_paypal_button_deleted',
885            array(
886                'environment'  => PayPal_OAuth::get_environment(),
887                'source'       => 'editor',
888                'already_gone' => false,
889            )
890        );
891
892        return new WP_REST_Response(
893            array(
894                'deleted'     => true,
895                'resource_id' => $resource_id,
896                'message'     => __( 'Payment resource deleted successfully.', 'jetpack-paypal-payments' ),
897            ),
898            200
899        );
900    }
901
902    // --- Shared helpers ---
903
904    /**
905     * Validate a button create/update request using PayPal_Attribute_Mapper.
906     *
907     * Extracts the line_items from the request, maps them to block attributes,
908     * and runs full validation before the API call is made. This catches
909     * issues that client-side validation might miss.
910     *
911     * @param WP_REST_Request $request The REST request.
912     * @return true|WP_Error True if valid, WP_Error on validation failure.
913     */
914    private static function validate_button_request( WP_REST_Request $request ) {
915        $line_items = $request->get_param( 'line_items' );
916
917        if ( empty( $line_items ) || ! is_array( $line_items ) ) {
918            return new WP_Error(
919                'missing_line_items',
920                __( 'At least one line item is required.', 'jetpack-paypal-payments' ),
921                array( 'status' => 400 )
922            );
923        }
924
925        // Extract the first line item into attribute-style format for validation.
926        $first_item = $line_items[0];
927        $attributes = array(
928            'productName' => $first_item['name'] ?? '',
929        );
930
931        if ( isset( $first_item['unit_amount'] ) && is_array( $first_item['unit_amount'] ) ) {
932            $attributes['price']        = $first_item['unit_amount']['value'] ?? '';
933            $attributes['currencyCode'] = $first_item['unit_amount']['currency_code'] ?? 'USD';
934        }
935
936        // Per-option prices stand in for the product-level price, so the
937        // validator needs them to know a missing `unit_amount` is legitimate.
938        if ( ! empty( $first_item['variants'] ) && is_array( $first_item['variants'] ) ) {
939            $attributes['variantsEnabled'] = true;
940            $attributes['variants']        = $first_item['variants'];
941
942            if ( ! isset( $attributes['currencyCode'] ) ) {
943                $attributes['currencyCode'] = self::get_variant_currency( $first_item['variants'] );
944            }
945        }
946
947        if ( ! empty( $first_item['product_id'] ) ) {
948            $attributes['productId'] = $first_item['product_id'];
949        }
950
951        if ( ! empty( $first_item['description'] ) ) {
952            $attributes['productDescription'] = $first_item['description'];
953        }
954
955        $return_url = $request->get_param( 'return_url' );
956        if ( ! empty( $return_url ) ) {
957            $attributes['returnUrl'] = $return_url;
958        }
959
960        $validation = PayPal_Attribute_Mapper::validate_attributes( $attributes );
961
962        if ( is_wp_error( $validation ) ) {
963            // Re-wrap with status for REST response.
964            $data = $validation->get_error_data();
965            return new WP_Error(
966                $validation->get_error_code(),
967                $validation->get_error_message(),
968                array( 'status' => $data['status'] ?? 400 )
969            );
970        }
971
972        return true;
973    }
974
975    /**
976     * Read the currency from the first priced variant option.
977     *
978     * Used when a line item has no product-level `unit_amount` because its
979     * options carry their own prices.
980     *
981     * @since 0.9.0
982     *
983     * @param array $variants Variants structure from the request.
984     * @return string The currency code, defaulting to USD.
985     */
986    private static function get_variant_currency( $variants ) {
987        foreach ( ( $variants['dimensions'] ?? array() ) as $dimension ) {
988            foreach ( ( $dimension['options'] ?? array() ) as $option ) {
989                if ( '' !== trim( (string) ( $option['unit_amount']['value'] ?? '' ) ) ) {
990                    return $option['unit_amount']['currency_code'] ?? 'USD';
991                }
992            }
993        }
994
995        return 'USD';
996    }
997
998    /**
999     * Record whether a connect attempt succeeded.
1000     *
1001     * @since 0.12.0
1002     *
1003     * @param mixed  $result      The connect result; a WP_Error when it failed.
1004     * @param string $environment The environment the connect used.
1005     * @param string $method      `manual` or `partner_referrals`.
1006     * @return void
1007     */
1008    private static function record_connection( $result, $environment, $method ) {
1009        $properties = array(
1010            'environment' => $environment,
1011            'method'      => $method,
1012        );
1013
1014        if ( is_wp_error( $result ) ) {
1015            // Send only the code, since the message can include PayPal's text.
1016            $properties['error_code'] = $result->get_error_code();
1017            PayPal_Tracks::record_event( 'jetpack_paypal_connection_failed', $properties );
1018            return;
1019        }
1020
1021        PayPal_Tracks::record_event( 'jetpack_paypal_connection_succeeded', $properties );
1022    }
1023
1024    /**
1025     * Tracks properties for a created payment link, from the data sent to PayPal.
1026     *
1027     * @since 0.12.0
1028     *
1029     * @param array $resource_data The data from build_resource_data().
1030     * @return array Event properties.
1031     */
1032    private static function get_button_event_properties( $resource_data ) {
1033        $line_item = $resource_data['line_items'][0] ?? array();
1034
1035        // Priced options replace the product-level price, so take the currency from the options.
1036        $currency = $line_item['unit_amount']['currency_code'] ?? self::get_variant_currency( $line_item['variants'] ?? array() );
1037
1038        return array(
1039            'environment'      => PayPal_OAuth::get_environment(),
1040            'integration_mode' => $resource_data['integration_mode'],
1041            'currency'         => strtoupper( $currency ),
1042            'has_variants'     => ! empty( $line_item['variants'] ),
1043        );
1044    }
1045
1046    /**
1047     * Get REST API arg definitions for button create/update endpoints.
1048     *
1049     * Defines the line_items schema matching PayPal's Pay Links & Buttons API.
1050     *
1051     * @return array REST API args definition.
1052     */
1053    private static function get_button_create_args() {
1054        // Shipping, handling and discounts take the same fields.
1055        $amount_list = array(
1056            'type'     => 'array',
1057            'required' => false,
1058            'items'    => array(
1059                'type'       => 'object',
1060                'properties' => array(
1061                    'type'                  => array( 'type' => 'string' ),
1062                    'value'                 => array( 'type' => 'string' ),
1063                    'additional_unit_value' => array( 'type' => 'string' ),
1064                ),
1065            ),
1066        );
1067
1068        return array(
1069            'name'             => array(
1070                'required'          => false,
1071                'type'              => 'string',
1072                'sanitize_callback' => 'sanitize_text_field',
1073                'description'       => __( 'Display name for the payment resource.', 'jetpack-paypal-payments' ),
1074            ),
1075            'type'             => array(
1076                'required'          => false,
1077                'type'              => 'string',
1078                'default'           => 'BUY_NOW',
1079                'enum'              => array( 'BUY_NOW' ),
1080                'sanitize_callback' => 'sanitize_text_field',
1081                'description'       => __( 'Payment type. Currently only BUY_NOW is supported.', 'jetpack-paypal-payments' ),
1082            ),
1083            'integration_mode' => array(
1084                'required'          => false,
1085                'type'              => 'string',
1086                'default'           => 'LINK',
1087                'enum'              => array( 'LINK', 'BUTTON' ),
1088                'sanitize_callback' => 'sanitize_text_field',
1089                'description'       => __( 'Integration mode. LINK returns a payment URL.', 'jetpack-paypal-payments' ),
1090            ),
1091            'reusable'         => array(
1092                'required'          => false,
1093                'type'              => 'string',
1094                'default'           => 'MULTIPLE',
1095                'enum'              => array( 'MULTIPLE', 'SINGLE' ),
1096                'sanitize_callback' => 'sanitize_text_field',
1097                'description'       => __( 'Whether the link can be used multiple times.', 'jetpack-paypal-payments' ),
1098            ),
1099            'return_url'       => array(
1100                'required'          => false,
1101                'type'              => 'string',
1102                'format'            => 'uri',
1103                'sanitize_callback' => 'esc_url_raw',
1104                'description'       => __( 'URL to redirect the buyer to after payment.', 'jetpack-paypal-payments' ),
1105            ),
1106            'line_items'       => array(
1107                'required'    => true,
1108                'type'        => 'array',
1109                'minItems'    => 1,
1110                'description' => __( 'Line items for the payment resource.', 'jetpack-paypal-payments' ),
1111                'items'       => array(
1112                    'type'       => 'object',
1113                    'properties' => array(
1114                        'name'                     => array(
1115                            'type'     => 'string',
1116                            'required' => true,
1117                        ),
1118                        'description'              => array(
1119                            'type'     => 'string',
1120                            'required' => false,
1121                        ),
1122                        // Not required: omitted when the product options carry
1123                        // their own per-option prices.
1124                        'unit_amount'              => array(
1125                            'type'       => 'object',
1126                            'required'   => false,
1127                            'properties' => array(
1128                                'currency_code' => array(
1129                                    'type'     => 'string',
1130                                    'required' => true,
1131                                ),
1132                                'value'         => array(
1133                                    'type'     => 'string',
1134                                    'required' => true,
1135                                ),
1136                            ),
1137                        ),
1138                        'quantity'                 => array(
1139                            'type'     => 'string',
1140                            'required' => false,
1141                            'default'  => '1',
1142                        ),
1143                        'variants'                 => array(
1144                            'type'       => 'object',
1145                            'required'   => false,
1146                            'properties' => array(
1147                                'dimensions' => array(
1148                                    'type'  => 'array',
1149                                    'items' => array(
1150                                        'type'       => 'object',
1151                                        'properties' => array(
1152                                            'name'    => array( 'type' => 'string' ),
1153                                            'primary' => array( 'type' => 'boolean' ),
1154                                            'options' => array(
1155                                                'type'  => 'array',
1156                                                'items' => array(
1157                                                    'type' => 'object',
1158                                                    'properties' => array(
1159                                                        'label'       => array( 'type' => 'string' ),
1160                                                        'unit_amount' => array(
1161                                                            'type'       => 'object',
1162                                                            'properties' => array(
1163                                                                'currency_code' => array( 'type' => 'string' ),
1164                                                                'value'         => array( 'type' => 'string' ),
1165                                                            ),
1166                                                        ),
1167                                                    ),
1168                                                ),
1169                                            ),
1170                                        ),
1171                                    ),
1172                                ),
1173                            ),
1174                        ),
1175                        'adjustable_quantity'      => array(
1176                            'type'       => 'object',
1177                            'required'   => false,
1178                            'properties' => array(
1179                                'maximum' => array( 'type' => 'integer' ),
1180                            ),
1181                        ),
1182                        'customer_notes'           => array(
1183                            'type'     => 'array',
1184                            'required' => false,
1185                            'items'    => array(
1186                                'type'       => 'object',
1187                                'properties' => array(
1188                                    'label'    => array( 'type' => 'string' ),
1189                                    'required' => array( 'type' => 'boolean' ),
1190                                ),
1191                            ),
1192                        ),
1193                        'taxes'                    => array(
1194                            'type'     => 'array',
1195                            'required' => false,
1196                            'items'    => array(
1197                                'type'       => 'object',
1198                                'properties' => array(
1199                                    'name'  => array( 'type' => 'string' ),
1200                                    'type'  => array(
1201                                        'type' => 'string',
1202                                        'enum' => array( 'PERCENTAGE', 'PREFERENCE', 'FLAT' ),
1203                                    ),
1204                                    'value' => array( 'type' => 'string' ),
1205                                ),
1206                            ),
1207                        ),
1208                        'product_id'               => array(
1209                            'type'     => 'string',
1210                            'required' => false,
1211                        ),
1212                        'shipping'                 => $amount_list,
1213                        'handling'                 => $amount_list,
1214                        'discounts'                => $amount_list,
1215                        'collect_shipping_address' => array(
1216                            'type'     => 'boolean',
1217                            'required' => false,
1218                        ),
1219                    ),
1220                ),
1221            ),
1222        );
1223    }
1224
1225    /**
1226     * Build the resource data array from a REST request for PayPal API submission.
1227     *
1228     * Sanitizes the line items and adds name and return_url when the request sent them.
1229     *
1230     * @param WP_REST_Request $request The incoming REST request.
1231     * @return array The sanitized resource data ready for the PayPal API.
1232     */
1233    private static function build_resource_data( WP_REST_Request $request ) {
1234        $data = array(
1235            'type'             => $request->get_param( 'type' ),
1236            'integration_mode' => $request->get_param( 'integration_mode' ),
1237            'reusable'         => $request->get_param( 'reusable' ),
1238            'line_items'       => $request->get_param( 'line_items' ),
1239        );
1240
1241        // Sanitize line_items deeply.
1242        if ( is_array( $data['line_items'] ) ) {
1243            $data['line_items'] = self::sanitize_line_items( $data['line_items'] );
1244        }
1245
1246        // Add optional fields only when present.
1247        $name = $request->get_param( 'name' );
1248        if ( ! empty( $name ) ) {
1249            $data['name'] = $name;
1250        }
1251
1252        $return_url = $request->get_param( 'return_url' );
1253        if ( ! empty( $return_url ) ) {
1254            $data['return_url'] = $return_url;
1255        }
1256
1257        return $data;
1258    }
1259
1260    /**
1261     * Sanitize line items array for PayPal API submission.
1262     *
1263     * @param array $line_items Raw line items from the REST request.
1264     * @return array Sanitized line items.
1265     */
1266    private static function sanitize_line_items( $line_items ) {
1267        $sanitized = array();
1268
1269        foreach ( $line_items as $item ) {
1270            $clean_item = array(
1271                'name' => isset( $item['name'] ) ? sanitize_text_field( $item['name'] ) : '',
1272            );
1273
1274            // Variants (product options with optional per-option pricing).
1275            if ( ! empty( $item['variants'] ) && is_array( $item['variants'] ) ) {
1276                $clean_item['variants'] = self::sanitize_variants( $item['variants'] );
1277            }
1278
1279            // PayPal rejects a line item that specifies `unit_amount` at both the
1280            // product level and the variant level, so per-option prices replace
1281            // the product-level price rather than sitting alongside it.
1282            if ( ! PayPal_Attribute_Mapper::variants_have_pricing( $clean_item['variants'] ?? null ) ) {
1283                $clean_item['unit_amount'] = array(
1284                    'currency_code' => isset( $item['unit_amount']['currency_code'] )
1285                        ? sanitize_text_field( $item['unit_amount']['currency_code'] )
1286                        : 'USD',
1287                    'value'         => isset( $item['unit_amount']['value'] )
1288                        ? sanitize_text_field( $item['unit_amount']['value'] )
1289                        : '0.00',
1290                );
1291            }
1292
1293            // Optional fields.
1294            if ( ! empty( $item['description'] ) ) {
1295                // The control is a textarea, so keep the line breaks PayPal stores.
1296                $clean_item['description'] = sanitize_textarea_field( $item['description'] );
1297            }
1298            if ( ! empty( $item['quantity'] ) ) {
1299                $clean_item['quantity'] = (string) max( 1, absint( $item['quantity'] ) );
1300            }
1301
1302            // Adjustable quantity configuration.
1303            if ( ! empty( $item['adjustable_quantity'] ) && is_array( $item['adjustable_quantity'] ) ) {
1304                $clean_item['adjustable_quantity'] = array(
1305                    'maximum' => isset( $item['adjustable_quantity']['maximum'] )
1306                        ? absint( $item['adjustable_quantity']['maximum'] )
1307                        : 10,
1308                );
1309            }
1310
1311            // Customer notes (custom checkout fields).
1312            if ( ! empty( $item['customer_notes'] ) && is_array( $item['customer_notes'] ) ) {
1313                $clean_notes = array();
1314                foreach ( $item['customer_notes'] as $note ) {
1315                    if ( is_array( $note ) && ! empty( $note['label'] ) ) {
1316                        $clean_notes[] = array(
1317                            'label'    => sanitize_text_field( $note['label'] ),
1318                            'required' => ! empty( $note['required'] ),
1319                        );
1320                    }
1321                }
1322                if ( ! empty( $clean_notes ) ) {
1323                    $clean_item['customer_notes'] = $clean_notes;
1324                }
1325            }
1326
1327            // The type decides how the value reads, so a type outside this list falls
1328            // back to PERCENTAGE and turns a flat 5.00 into 5%.
1329            if ( ! empty( $item['taxes'] ) && is_array( $item['taxes'] ) ) {
1330                $clean_taxes = array();
1331                $valid_types = array( 'PERCENTAGE', 'PREFERENCE', 'FLAT' );
1332                foreach ( $item['taxes'] as $tax ) {
1333                    // No name: PayPal labels the tax itself, and requiring one here
1334                    // threw the whole tax away.
1335                    if ( is_array( $tax ) ) {
1336                        $tax_type = isset( $tax['type'] ) ? sanitize_text_field( $tax['type'] ) : 'PERCENTAGE';
1337                        if ( ! in_array( $tax_type, $valid_types, true ) ) {
1338                            $tax_type = 'PERCENTAGE';
1339                        }
1340
1341                        if ( 'PREFERENCE' === $tax_type ) {
1342                            // The rate comes from the merchant's PayPal profile.
1343                            $tax_value = 'PROFILE';
1344                        } elseif ( 'FLAT' === $tax_type ) {
1345                            // A flat tax is an amount, not a rate - keep a string as sent
1346                            // so '1.50' does not become 1.5. PayPal validates it itself.
1347                            $tax_value = trim( sanitize_text_field( (string) ( $tax['value'] ?? '0' ) ) );
1348                            if ( '' === $tax_value ) {
1349                                $tax_value = '0';
1350                            }
1351                        } else {
1352                            $tax_value = (string) max( 0, floatval( $tax['value'] ?? 0 ) );
1353                        }
1354
1355                        $clean_tax = array(
1356                            'type'  => $tax_type,
1357                            'value' => $tax_value,
1358                        );
1359
1360                        // name is optional, so only send a real one - an empty string
1361                        // is not a name.
1362                        if ( ! empty( $tax['name'] ) ) {
1363                            $clean_tax['name'] = sanitize_text_field( $tax['name'] );
1364                        }
1365
1366                        $clean_taxes[] = $clean_tax;
1367                    }
1368                }
1369                if ( ! empty( $clean_taxes ) ) {
1370                    $clean_item['taxes'] = $clean_taxes;
1371                }
1372            }
1373
1374            // An id of only whitespace or tags sanitizes down to '', and PayPal rejects an
1375            // empty one, so drop it.
1376            $product_id = sanitize_text_field( (string) ( $item['product_id'] ?? '' ) );
1377            if ( '' !== $product_id ) {
1378                $clean_item['product_id'] = $product_id;
1379            }
1380            foreach ( array( 'shipping', 'handling', 'discounts' ) as $field ) {
1381                if ( ! empty( $item[ $field ] ) && is_array( $item[ $field ] ) ) {
1382                    $clean_amounts = self::sanitize_amount_list( $item[ $field ] );
1383                    if ( ! empty( $clean_amounts ) ) {
1384                        $clean_item[ $field ] = $clean_amounts;
1385                    }
1386                }
1387            }
1388
1389            // Send it even when off - omit it and PayPal turns address collection back on.
1390            if ( isset( $item['collect_shipping_address'] ) ) {
1391                $clean_item['collect_shipping_address'] = (bool) $item['collect_shipping_address'];
1392            }
1393
1394            $sanitized[] = $clean_item;
1395        }
1396
1397        return $sanitized;
1398    }
1399
1400    /**
1401     * Sanitize a shipping, handling or discount list for PayPal API submission.
1402     *
1403     * All three take a type, a value, and for per-unit shipping a rate per extra unit.
1404     * The type passes through as sent, so one set in PayPal's dashboard survives a PUT.
1405     *
1406     * @param array $amounts Raw entries from the REST request.
1407     * @return array Sanitized entries.
1408     */
1409    private static function sanitize_amount_list( $amounts ) {
1410        $clean = array();
1411
1412        foreach ( $amounts as $amount ) {
1413            // A zero is a legitimate amount, and in a zero-decimal currency it is
1414            // written "0", which empty() would throw away.
1415            if ( ! is_array( $amount ) || ! isset( $amount['value'] ) || '' === (string) $amount['value'] ) {
1416                continue;
1417            }
1418
1419            $clean_amount = array(
1420                'type'  => isset( $amount['type'] ) ? sanitize_text_field( $amount['type'] ) : 'FLAT',
1421                'value' => sanitize_text_field( (string) $amount['value'] ),
1422            );
1423
1424            if ( isset( $amount['additional_unit_value'] ) && '' !== (string) $amount['additional_unit_value'] ) {
1425                $clean_amount['additional_unit_value'] = sanitize_text_field( (string) $amount['additional_unit_value'] );
1426            }
1427
1428            $clean[] = $clean_amount;
1429        }
1430
1431        return $clean;
1432    }
1433
1434    /**
1435     * Sanitize variant dimensions and options.
1436     *
1437     * @param array $variants Raw variants data from the REST request.
1438     * @return array Sanitized variants.
1439     */
1440    private static function sanitize_variants( $variants ) {
1441        $clean = array();
1442
1443        if ( ! empty( $variants['dimensions'] ) && is_array( $variants['dimensions'] ) ) {
1444            $clean_dims = array();
1445            foreach ( $variants['dimensions'] as $dimension ) {
1446                if ( ! is_array( $dimension ) ) {
1447                    continue;
1448                }
1449                $clean_dim = array(
1450                    'name'    => isset( $dimension['name'] ) ? sanitize_text_field( $dimension['name'] ) : '',
1451                    'primary' => ! empty( $dimension['primary'] ),
1452                );
1453
1454                if ( ! empty( $dimension['options'] ) && is_array( $dimension['options'] ) ) {
1455                    $clean_opts = array();
1456                    foreach ( $dimension['options'] as $option ) {
1457                        if ( ! is_array( $option ) ) {
1458                            continue;
1459                        }
1460                        $clean_opt = array(
1461                            'label' => isset( $option['label'] ) ? sanitize_text_field( $option['label'] ) : '',
1462                        );
1463                        // Per-option pricing, on the primary dimension only. The
1464                        // editor sends an empty value for un-priced options —
1465                        // that's "no price", not a price of zero, and passing it
1466                        // through would make PayPal see variant-level pricing.
1467                        if ( $clean_dim['primary'] && ! empty( $option['unit_amount'] ) && is_array( $option['unit_amount'] ) ) {
1468                            $value = trim( sanitize_text_field( (string) ( $option['unit_amount']['value'] ?? '' ) ) );
1469                            if ( '' !== $value ) {
1470                                $clean_opt['unit_amount'] = array(
1471                                    'currency_code' => isset( $option['unit_amount']['currency_code'] )
1472                                        ? sanitize_text_field( $option['unit_amount']['currency_code'] )
1473                                        : 'USD',
1474                                    'value'         => $value,
1475                                );
1476                            }
1477                        }
1478                        $clean_opts[] = $clean_opt;
1479                    }
1480                    $clean_dim['options'] = $clean_opts;
1481                }
1482
1483                $clean_dims[] = $clean_dim;
1484            }
1485            $clean['dimensions'] = $clean_dims;
1486        }
1487
1488        return $clean;
1489    }
1490
1491    /**
1492     * Convert a PayPal API WP_Error into a REST-appropriate WP_Error with HTTP status.
1493     *
1494     * Preserves the original error code and message, extracting the HTTP status
1495     * from the error data if available.
1496     *
1497     * @param WP_Error $error The API client error.
1498     * @return WP_Error Error with appropriate REST status code.
1499     */
1500    public static function api_error_to_rest_error( WP_Error $error ) {
1501        $data   = $error->get_error_data();
1502        $status = $data['status'] ?? 500;
1503
1504        // Ensure we never return a 0 status (network errors).
1505        if ( 0 === $status || empty( $status ) ) {
1506            $status = 503;
1507        }
1508
1509        $rest_data = array( 'status' => $status );
1510
1511        // PayPal's debug ID is what their support resolves a failed request by, so
1512        // keep it for the editor and record it. Send only the code, not the message.
1513        if ( ! empty( $data['paypal_debug_id'] ) ) {
1514            $rest_data['paypal_debug_id'] = $data['paypal_debug_id'];
1515            PayPal_Tracks::record_event(
1516                'jetpack_paypal_api_error',
1517                array(
1518                    'environment' => PayPal_OAuth::get_environment(),
1519                    'error_code'  => $error->get_error_code(),
1520                    'status'      => (int) $status,
1521                    'debug_id'    => $data['paypal_debug_id'],
1522                )
1523            );
1524        }
1525
1526        return new WP_Error(
1527            $error->get_error_code(),
1528            $error->get_error_message(),
1529            $rest_data
1530        );
1531    }
1532}