Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.49% covered (success)
97.49%
856 / 878
64.29% covered (warning)
64.29%
18 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_REST_Controller
97.72% covered (success)
97.72%
856 / 876
64.29% covered (warning)
64.29%
18 / 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
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 handle_create_button
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 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
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 get_button_create_args
100.00% covered (success)
100.00%
169 / 169
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.37% covered (success)
97.37%
74 / 76
0.00% covered (danger)
0.00%
0 / 1
38
 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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
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 link 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 link 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 $$next-version$$
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::PAYPAL_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        $result = PayPal_API_Client::update_resource( $resource_id, $resource_data );
794
795        if ( is_wp_error( $result ) ) {
796            return self::api_error_to_rest_error( $result );
797        }
798
799        // PayPal answers a PUT with 204 and no code_snippets, so a caller that needs them asks
800        // for the resource to be read back; that read is how a block switching to stacked draws
801        // in the same save rather than after a reload. Only the re-read gets `attributes`: the
802        // echo has no `id`, and mapping it would blank the block's resourceId.
803        if ( $request->get_param( 'include_snippets' ) ) {
804            $fresh = PayPal_API_Client::get_resource( $resource_id );
805            if ( ! is_wp_error( $fresh ) ) {
806                $result               = $fresh;
807                $result['attributes'] = PayPal_Attribute_Mapper::api_response_to_attributes( $result );
808            }
809        }
810
811        return new WP_REST_Response( $result, 200 );
812    }
813
814    /**
815     * Handle DELETE /paypal/buttons/{resource_id} -- delete a payment resource.
816     *
817     * @param WP_REST_Request $request The REST request.
818     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
819     */
820    public static function handle_delete_button( WP_REST_Request $request ) {
821        $resource_id = $request->get_param( 'resource_id' );
822
823        // Spare a link another published post still embeds.
824        if ( $request->get_param( 'unused_only' ) ) {
825            $embeds = PayPal_Admin_Page::count_published_embeds( absint( $request->get_param( 'post_id' ) ) );
826            $others = $embeds[ $resource_id ] ?? 0;
827            if ( $others > 0 ) {
828                return new WP_REST_Response(
829                    array(
830                        'deleted'     => false,
831                        'resource_id' => $resource_id,
832                        'message'     => sprintf(
833                            /* translators: %d: number of published posts */
834                            _n(
835                                'Kept the payment link: %d other published post still uses it.',
836                                'Kept the payment link: %d other published posts still use it.',
837                                $others,
838                                'jetpack-paypal-payments'
839                            ),
840                            $others
841                        ),
842                    ),
843                    200
844                );
845            }
846        }
847
848        $result = PayPal_API_Client::delete_resource( $resource_id );
849
850        if ( is_wp_error( $result ) ) {
851            // If the resource is already gone (404), treat as success.
852            $error_data = $result->get_error_data();
853            if ( isset( $error_data['status'] ) && 404 === (int) $error_data['status'] ) {
854                PayPal_Tracks::record_event(
855                    'jetpack_paypal_button_deleted',
856                    array(
857                        'environment'  => PayPal_OAuth::get_environment(),
858                        'source'       => 'editor',
859                        'already_gone' => true,
860                    )
861                );
862
863                return new WP_REST_Response(
864                    array(
865                        'deleted'     => true,
866                        'resource_id' => $resource_id,
867                        'message'     => __( 'Payment resource was already deleted from PayPal.', 'jetpack-paypal-payments' ),
868                    ),
869                    200
870                );
871            }
872
873            return self::api_error_to_rest_error( $result );
874        }
875
876        PayPal_Tracks::record_event(
877            'jetpack_paypal_button_deleted',
878            array(
879                'environment'  => PayPal_OAuth::get_environment(),
880                'source'       => 'editor',
881                'already_gone' => false,
882            )
883        );
884
885        return new WP_REST_Response(
886            array(
887                'deleted'     => true,
888                'resource_id' => $resource_id,
889                'message'     => __( 'Payment resource deleted successfully.', 'jetpack-paypal-payments' ),
890            ),
891            200
892        );
893    }
894
895    // --- Shared helpers ---
896
897    /**
898     * Validate a button create/update request using PayPal_Attribute_Mapper.
899     *
900     * Extracts the line_items from the request, maps them to block attributes,
901     * and runs full validation before the API call is made. This catches
902     * issues that client-side validation might miss.
903     *
904     * @param WP_REST_Request $request The REST request.
905     * @return true|WP_Error True if valid, WP_Error on validation failure.
906     */
907    private static function validate_button_request( WP_REST_Request $request ) {
908        $line_items = $request->get_param( 'line_items' );
909
910        if ( empty( $line_items ) || ! is_array( $line_items ) ) {
911            return new WP_Error(
912                'missing_line_items',
913                __( 'At least one line item is required.', 'jetpack-paypal-payments' ),
914                array( 'status' => 400 )
915            );
916        }
917
918        // Extract the first line item into attribute-style format for validation.
919        $first_item = $line_items[0];
920        $attributes = array(
921            'productName' => $first_item['name'] ?? '',
922        );
923
924        if ( isset( $first_item['unit_amount'] ) && is_array( $first_item['unit_amount'] ) ) {
925            $attributes['price']        = $first_item['unit_amount']['value'] ?? '';
926            $attributes['currencyCode'] = $first_item['unit_amount']['currency_code'] ?? 'USD';
927        }
928
929        // Per-option prices stand in for the product-level price, so the
930        // validator needs them to know a missing `unit_amount` is legitimate.
931        if ( ! empty( $first_item['variants'] ) && is_array( $first_item['variants'] ) ) {
932            $attributes['variantsEnabled'] = true;
933            $attributes['variants']        = $first_item['variants'];
934
935            if ( ! isset( $attributes['currencyCode'] ) ) {
936                $attributes['currencyCode'] = self::get_variant_currency( $first_item['variants'] );
937            }
938        }
939
940        if ( ! empty( $first_item['product_id'] ) ) {
941            $attributes['productId'] = $first_item['product_id'];
942        }
943
944        if ( ! empty( $first_item['description'] ) ) {
945            $attributes['productDescription'] = $first_item['description'];
946        }
947
948        $return_url = $request->get_param( 'return_url' );
949        if ( ! empty( $return_url ) ) {
950            $attributes['returnUrl'] = $return_url;
951        }
952
953        $validation = PayPal_Attribute_Mapper::validate_attributes( $attributes );
954
955        if ( is_wp_error( $validation ) ) {
956            // Re-wrap with status for REST response.
957            $data = $validation->get_error_data();
958            return new WP_Error(
959                $validation->get_error_code(),
960                $validation->get_error_message(),
961                array( 'status' => $data['status'] ?? 400 )
962            );
963        }
964
965        return true;
966    }
967
968    /**
969     * Read the currency from the first priced variant option.
970     *
971     * Used when a line item has no product-level `unit_amount` because its
972     * options carry their own prices.
973     *
974     * @since 0.9.0
975     *
976     * @param array $variants Variants structure from the request.
977     * @return string The currency code, defaulting to USD.
978     */
979    private static function get_variant_currency( $variants ) {
980        foreach ( ( $variants['dimensions'] ?? array() ) as $dimension ) {
981            foreach ( ( $dimension['options'] ?? array() ) as $option ) {
982                if ( '' !== trim( (string) ( $option['unit_amount']['value'] ?? '' ) ) ) {
983                    return $option['unit_amount']['currency_code'] ?? 'USD';
984                }
985            }
986        }
987
988        return 'USD';
989    }
990
991    /**
992     * Record whether a connect attempt succeeded.
993     *
994     * @since $$next-version$$
995     *
996     * @param mixed  $result      The connect result; a WP_Error when it failed.
997     * @param string $environment The environment the connect used.
998     * @param string $method      `manual` or `partner_referrals`.
999     * @return void
1000     */
1001    private static function record_connection( $result, $environment, $method ) {
1002        $properties = array(
1003            'environment' => $environment,
1004            'method'      => $method,
1005        );
1006
1007        if ( is_wp_error( $result ) ) {
1008            // Send only the code, since the message can include PayPal's text.
1009            $properties['error_code'] = $result->get_error_code();
1010            PayPal_Tracks::record_event( 'jetpack_paypal_connection_failed', $properties );
1011            return;
1012        }
1013
1014        PayPal_Tracks::record_event( 'jetpack_paypal_connection_succeeded', $properties );
1015    }
1016
1017    /**
1018     * Tracks properties for a created payment link, from the data sent to PayPal.
1019     *
1020     * @since $$next-version$$
1021     *
1022     * @param array $resource_data The data from build_resource_data().
1023     * @return array Event properties.
1024     */
1025    private static function get_button_event_properties( $resource_data ) {
1026        $line_item = $resource_data['line_items'][0] ?? array();
1027
1028        // Priced options replace the product-level price, so take the currency from the options.
1029        $currency = $line_item['unit_amount']['currency_code'] ?? self::get_variant_currency( $line_item['variants'] ?? array() );
1030
1031        return array(
1032            'environment'      => PayPal_OAuth::get_environment(),
1033            'integration_mode' => $resource_data['integration_mode'],
1034            'currency'         => strtoupper( $currency ),
1035            'has_variants'     => ! empty( $line_item['variants'] ),
1036            'has_image'        => ! empty( $line_item['image_url'] ),
1037        );
1038    }
1039
1040    /**
1041     * Get REST API arg definitions for button create/update endpoints.
1042     *
1043     * Defines the line_items schema matching PayPal's Pay Links & Buttons API.
1044     *
1045     * @return array REST API args definition.
1046     */
1047    private static function get_button_create_args() {
1048        // Shipping, handling and discounts take the same fields.
1049        $amount_list = array(
1050            'type'     => 'array',
1051            'required' => false,
1052            'items'    => array(
1053                'type'       => 'object',
1054                'properties' => array(
1055                    'type'                  => array( 'type' => 'string' ),
1056                    'value'                 => array( 'type' => 'string' ),
1057                    'additional_unit_value' => array( 'type' => 'string' ),
1058                ),
1059            ),
1060        );
1061
1062        return array(
1063            'name'             => array(
1064                'required'          => false,
1065                'type'              => 'string',
1066                'sanitize_callback' => 'sanitize_text_field',
1067                'description'       => __( 'Display name for the payment resource.', 'jetpack-paypal-payments' ),
1068            ),
1069            'type'             => array(
1070                'required'          => false,
1071                'type'              => 'string',
1072                'default'           => 'BUY_NOW',
1073                'enum'              => array( 'BUY_NOW' ),
1074                'sanitize_callback' => 'sanitize_text_field',
1075                'description'       => __( 'Payment type. Currently only BUY_NOW is supported.', 'jetpack-paypal-payments' ),
1076            ),
1077            'integration_mode' => array(
1078                'required'          => false,
1079                'type'              => 'string',
1080                'default'           => 'LINK',
1081                'enum'              => array( 'LINK', 'BUTTON' ),
1082                'sanitize_callback' => 'sanitize_text_field',
1083                'description'       => __( 'Integration mode. LINK returns a payment URL.', 'jetpack-paypal-payments' ),
1084            ),
1085            'reusable'         => array(
1086                'required'          => false,
1087                'type'              => 'string',
1088                'default'           => 'MULTIPLE',
1089                'enum'              => array( 'MULTIPLE', 'SINGLE' ),
1090                'sanitize_callback' => 'sanitize_text_field',
1091                'description'       => __( 'Whether the link can be used multiple times.', 'jetpack-paypal-payments' ),
1092            ),
1093            'return_url'       => array(
1094                'required'          => false,
1095                'type'              => 'string',
1096                'format'            => 'uri',
1097                'sanitize_callback' => 'esc_url_raw',
1098                'description'       => __( 'URL to redirect the buyer to after payment.', 'jetpack-paypal-payments' ),
1099            ),
1100            'line_items'       => array(
1101                'required'    => true,
1102                'type'        => 'array',
1103                'minItems'    => 1,
1104                'description' => __( 'Line items for the payment resource.', 'jetpack-paypal-payments' ),
1105                'items'       => array(
1106                    'type'       => 'object',
1107                    'properties' => array(
1108                        'name'                     => array(
1109                            'type'     => 'string',
1110                            'required' => true,
1111                        ),
1112                        'description'              => array(
1113                            'type'     => 'string',
1114                            'required' => false,
1115                        ),
1116                        // Shown on the PayPal checkout. The sanitizer keeps it only when HTTPS.
1117                        'image_url'                => array(
1118                            'type'     => 'string',
1119                            'required' => false,
1120                        ),
1121                        // Not required: omitted when the product options carry
1122                        // their own per-option prices.
1123                        'unit_amount'              => array(
1124                            'type'       => 'object',
1125                            'required'   => false,
1126                            'properties' => array(
1127                                'currency_code' => array(
1128                                    'type'     => 'string',
1129                                    'required' => true,
1130                                ),
1131                                'value'         => array(
1132                                    'type'     => 'string',
1133                                    'required' => true,
1134                                ),
1135                            ),
1136                        ),
1137                        'quantity'                 => array(
1138                            'type'     => 'string',
1139                            'required' => false,
1140                            'default'  => '1',
1141                        ),
1142                        'variants'                 => array(
1143                            'type'       => 'object',
1144                            'required'   => false,
1145                            'properties' => array(
1146                                'dimensions' => array(
1147                                    'type'  => 'array',
1148                                    'items' => array(
1149                                        'type'       => 'object',
1150                                        'properties' => array(
1151                                            'name'    => array( 'type' => 'string' ),
1152                                            'primary' => array( 'type' => 'boolean' ),
1153                                            'options' => array(
1154                                                'type'  => 'array',
1155                                                'items' => array(
1156                                                    'type' => 'object',
1157                                                    'properties' => array(
1158                                                        'label'       => array( 'type' => 'string' ),
1159                                                        'unit_amount' => array(
1160                                                            'type'       => 'object',
1161                                                            'properties' => array(
1162                                                                'currency_code' => array( 'type' => 'string' ),
1163                                                                'value'         => array( 'type' => 'string' ),
1164                                                            ),
1165                                                        ),
1166                                                    ),
1167                                                ),
1168                                            ),
1169                                        ),
1170                                    ),
1171                                ),
1172                            ),
1173                        ),
1174                        'adjustable_quantity'      => array(
1175                            'type'       => 'object',
1176                            'required'   => false,
1177                            'properties' => array(
1178                                'maximum' => array( 'type' => 'integer' ),
1179                            ),
1180                        ),
1181                        'customer_notes'           => array(
1182                            'type'     => 'array',
1183                            'required' => false,
1184                            'items'    => array(
1185                                'type'       => 'object',
1186                                'properties' => array(
1187                                    'label'    => array( 'type' => 'string' ),
1188                                    'required' => array( 'type' => 'boolean' ),
1189                                ),
1190                            ),
1191                        ),
1192                        'taxes'                    => array(
1193                            'type'     => 'array',
1194                            'required' => false,
1195                            'items'    => array(
1196                                'type'       => 'object',
1197                                'properties' => array(
1198                                    'name'  => array( 'type' => 'string' ),
1199                                    'type'  => array(
1200                                        'type' => 'string',
1201                                        'enum' => array( 'PERCENTAGE', 'PREFERENCE', 'FLAT' ),
1202                                    ),
1203                                    'value' => array( 'type' => 'string' ),
1204                                ),
1205                            ),
1206                        ),
1207                        'product_id'               => array(
1208                            'type'     => 'string',
1209                            'required' => false,
1210                        ),
1211                        'shipping'                 => $amount_list,
1212                        'handling'                 => $amount_list,
1213                        'discounts'                => $amount_list,
1214                        'collect_shipping_address' => array(
1215                            'type'     => 'boolean',
1216                            'required' => false,
1217                        ),
1218                    ),
1219                ),
1220            ),
1221        );
1222    }
1223
1224    /**
1225     * Build the resource data array from a REST request for PayPal API submission.
1226     *
1227     * Sanitizes the line items and adds name and return_url when the request sent them.
1228     *
1229     * @param WP_REST_Request $request The incoming REST request.
1230     * @return array The sanitized resource data ready for the PayPal API.
1231     */
1232    private static function build_resource_data( WP_REST_Request $request ) {
1233        $data = array(
1234            'type'             => $request->get_param( 'type' ),
1235            'integration_mode' => $request->get_param( 'integration_mode' ),
1236            'reusable'         => $request->get_param( 'reusable' ),
1237            'line_items'       => $request->get_param( 'line_items' ),
1238        );
1239
1240        // Sanitize line_items deeply.
1241        if ( is_array( $data['line_items'] ) ) {
1242            $data['line_items'] = self::sanitize_line_items( $data['line_items'] );
1243        }
1244
1245        // Add optional fields only when present.
1246        $name = $request->get_param( 'name' );
1247        if ( ! empty( $name ) ) {
1248            $data['name'] = $name;
1249        }
1250
1251        $return_url = $request->get_param( 'return_url' );
1252        if ( ! empty( $return_url ) ) {
1253            $data['return_url'] = $return_url;
1254        }
1255
1256        return $data;
1257    }
1258
1259    /**
1260     * Sanitize line items array for PayPal API submission.
1261     *
1262     * @param array $line_items Raw line items from the REST request.
1263     * @return array Sanitized line items.
1264     */
1265    private static function sanitize_line_items( $line_items ) {
1266        $sanitized = array();
1267
1268        foreach ( $line_items as $item ) {
1269            $clean_item = array(
1270                'name' => isset( $item['name'] ) ? sanitize_text_field( $item['name'] ) : '',
1271            );
1272
1273            // Variants (product options with optional per-option pricing).
1274            if ( ! empty( $item['variants'] ) && is_array( $item['variants'] ) ) {
1275                $clean_item['variants'] = self::sanitize_variants( $item['variants'] );
1276            }
1277
1278            // PayPal rejects a line item that specifies `unit_amount` at both the
1279            // product level and the variant level, so per-option prices replace
1280            // the product-level price rather than sitting alongside it.
1281            if ( ! PayPal_Attribute_Mapper::variants_have_pricing( $clean_item['variants'] ?? null ) ) {
1282                $clean_item['unit_amount'] = array(
1283                    'currency_code' => isset( $item['unit_amount']['currency_code'] )
1284                        ? sanitize_text_field( $item['unit_amount']['currency_code'] )
1285                        : 'USD',
1286                    'value'         => isset( $item['unit_amount']['value'] )
1287                        ? sanitize_text_field( $item['unit_amount']['value'] )
1288                        : '0.00',
1289                );
1290            }
1291
1292            // Optional fields.
1293            if ( ! empty( $item['description'] ) ) {
1294                // The control is a textarea, so keep the line breaks PayPal stores.
1295                $clean_item['description'] = sanitize_textarea_field( $item['description'] );
1296            }
1297
1298            // PayPal fetches the image itself, so anything but a public HTTPS URL is
1299            // dropped rather than rejected: an http:// site can still save its button.
1300            if ( ! empty( $item['image_url'] ) ) {
1301                $image_url = esc_url_raw( (string) $item['image_url'], array( 'https' ) );
1302                if ( 0 === strpos( $image_url, 'https://' ) ) {
1303                    $clean_item['image_url'] = $image_url;
1304                }
1305            }
1306            if ( ! empty( $item['quantity'] ) ) {
1307                $clean_item['quantity'] = (string) max( 1, absint( $item['quantity'] ) );
1308            }
1309
1310            // Adjustable quantity configuration.
1311            if ( ! empty( $item['adjustable_quantity'] ) && is_array( $item['adjustable_quantity'] ) ) {
1312                $clean_item['adjustable_quantity'] = array(
1313                    'maximum' => isset( $item['adjustable_quantity']['maximum'] )
1314                        ? absint( $item['adjustable_quantity']['maximum'] )
1315                        : 10,
1316                );
1317            }
1318
1319            // Customer notes (custom checkout fields).
1320            if ( ! empty( $item['customer_notes'] ) && is_array( $item['customer_notes'] ) ) {
1321                $clean_notes = array();
1322                foreach ( $item['customer_notes'] as $note ) {
1323                    if ( is_array( $note ) && ! empty( $note['label'] ) ) {
1324                        $clean_notes[] = array(
1325                            'label'    => sanitize_text_field( $note['label'] ),
1326                            'required' => ! empty( $note['required'] ),
1327                        );
1328                    }
1329                }
1330                if ( ! empty( $clean_notes ) ) {
1331                    $clean_item['customer_notes'] = $clean_notes;
1332                }
1333            }
1334
1335            // The type decides how the value reads, so a type outside this list falls
1336            // back to PERCENTAGE and turns a flat 5.00 into 5%.
1337            if ( ! empty( $item['taxes'] ) && is_array( $item['taxes'] ) ) {
1338                $clean_taxes = array();
1339                $valid_types = array( 'PERCENTAGE', 'PREFERENCE', 'FLAT' );
1340                foreach ( $item['taxes'] as $tax ) {
1341                    // No name: PayPal labels the tax itself, and requiring one here
1342                    // threw the whole tax away.
1343                    if ( is_array( $tax ) ) {
1344                        $tax_type = isset( $tax['type'] ) ? sanitize_text_field( $tax['type'] ) : 'PERCENTAGE';
1345                        if ( ! in_array( $tax_type, $valid_types, true ) ) {
1346                            $tax_type = 'PERCENTAGE';
1347                        }
1348
1349                        if ( 'PREFERENCE' === $tax_type ) {
1350                            // The rate comes from the merchant's PayPal profile.
1351                            $tax_value = 'PROFILE';
1352                        } elseif ( 'FLAT' === $tax_type ) {
1353                            // A flat tax is an amount, not a rate - keep a string as sent
1354                            // so '1.50' does not become 1.5. PayPal validates it itself.
1355                            $tax_value = trim( sanitize_text_field( (string) ( $tax['value'] ?? '0' ) ) );
1356                            if ( '' === $tax_value ) {
1357                                $tax_value = '0';
1358                            }
1359                        } else {
1360                            $tax_value = (string) max( 0, floatval( $tax['value'] ?? 0 ) );
1361                        }
1362
1363                        $clean_tax = array(
1364                            'type'  => $tax_type,
1365                            'value' => $tax_value,
1366                        );
1367
1368                        // name is optional, so only send a real one - an empty string
1369                        // is not a name.
1370                        if ( ! empty( $tax['name'] ) ) {
1371                            $clean_tax['name'] = sanitize_text_field( $tax['name'] );
1372                        }
1373
1374                        $clean_taxes[] = $clean_tax;
1375                    }
1376                }
1377                if ( ! empty( $clean_taxes ) ) {
1378                    $clean_item['taxes'] = $clean_taxes;
1379                }
1380            }
1381
1382            // An id of only whitespace or tags sanitizes down to '', and PayPal rejects an
1383            // empty one, so drop it.
1384            $product_id = sanitize_text_field( (string) ( $item['product_id'] ?? '' ) );
1385            if ( '' !== $product_id ) {
1386                $clean_item['product_id'] = $product_id;
1387            }
1388            foreach ( array( 'shipping', 'handling', 'discounts' ) as $field ) {
1389                if ( ! empty( $item[ $field ] ) && is_array( $item[ $field ] ) ) {
1390                    $clean_amounts = self::sanitize_amount_list( $item[ $field ] );
1391                    if ( ! empty( $clean_amounts ) ) {
1392                        $clean_item[ $field ] = $clean_amounts;
1393                    }
1394                }
1395            }
1396
1397            // Send it even when off - omit it and PayPal turns address collection back on.
1398            if ( isset( $item['collect_shipping_address'] ) ) {
1399                $clean_item['collect_shipping_address'] = (bool) $item['collect_shipping_address'];
1400            }
1401
1402            $sanitized[] = $clean_item;
1403        }
1404
1405        return $sanitized;
1406    }
1407
1408    /**
1409     * Sanitize a shipping, handling or discount list for PayPal API submission.
1410     *
1411     * All three take a type, a value, and for per-unit shipping a rate per extra unit.
1412     * The type passes through as sent, so one set in PayPal's dashboard survives a PUT.
1413     *
1414     * @param array $amounts Raw entries from the REST request.
1415     * @return array Sanitized entries.
1416     */
1417    private static function sanitize_amount_list( $amounts ) {
1418        $clean = array();
1419
1420        foreach ( $amounts as $amount ) {
1421            // A zero is a legitimate amount, and in a zero-decimal currency it is
1422            // written "0", which empty() would throw away.
1423            if ( ! is_array( $amount ) || ! isset( $amount['value'] ) || '' === (string) $amount['value'] ) {
1424                continue;
1425            }
1426
1427            $clean_amount = array(
1428                'type'  => isset( $amount['type'] ) ? sanitize_text_field( $amount['type'] ) : 'FLAT',
1429                'value' => sanitize_text_field( (string) $amount['value'] ),
1430            );
1431
1432            if ( isset( $amount['additional_unit_value'] ) && '' !== (string) $amount['additional_unit_value'] ) {
1433                $clean_amount['additional_unit_value'] = sanitize_text_field( (string) $amount['additional_unit_value'] );
1434            }
1435
1436            $clean[] = $clean_amount;
1437        }
1438
1439        return $clean;
1440    }
1441
1442    /**
1443     * Sanitize variant dimensions and options.
1444     *
1445     * @param array $variants Raw variants data from the REST request.
1446     * @return array Sanitized variants.
1447     */
1448    private static function sanitize_variants( $variants ) {
1449        $clean = array();
1450
1451        if ( ! empty( $variants['dimensions'] ) && is_array( $variants['dimensions'] ) ) {
1452            $clean_dims = array();
1453            foreach ( $variants['dimensions'] as $dimension ) {
1454                if ( ! is_array( $dimension ) ) {
1455                    continue;
1456                }
1457                $clean_dim = array(
1458                    'name'    => isset( $dimension['name'] ) ? sanitize_text_field( $dimension['name'] ) : '',
1459                    'primary' => ! empty( $dimension['primary'] ),
1460                );
1461
1462                if ( ! empty( $dimension['options'] ) && is_array( $dimension['options'] ) ) {
1463                    $clean_opts = array();
1464                    foreach ( $dimension['options'] as $option ) {
1465                        if ( ! is_array( $option ) ) {
1466                            continue;
1467                        }
1468                        $clean_opt = array(
1469                            'label' => isset( $option['label'] ) ? sanitize_text_field( $option['label'] ) : '',
1470                        );
1471                        // Per-option pricing, on the primary dimension only. The
1472                        // editor sends an empty value for un-priced options —
1473                        // that's "no price", not a price of zero, and passing it
1474                        // through would make PayPal see variant-level pricing.
1475                        if ( $clean_dim['primary'] && ! empty( $option['unit_amount'] ) && is_array( $option['unit_amount'] ) ) {
1476                            $value = trim( sanitize_text_field( (string) ( $option['unit_amount']['value'] ?? '' ) ) );
1477                            if ( '' !== $value ) {
1478                                $clean_opt['unit_amount'] = array(
1479                                    'currency_code' => isset( $option['unit_amount']['currency_code'] )
1480                                        ? sanitize_text_field( $option['unit_amount']['currency_code'] )
1481                                        : 'USD',
1482                                    'value'         => $value,
1483                                );
1484                            }
1485                        }
1486                        $clean_opts[] = $clean_opt;
1487                    }
1488                    $clean_dim['options'] = $clean_opts;
1489                }
1490
1491                $clean_dims[] = $clean_dim;
1492            }
1493            $clean['dimensions'] = $clean_dims;
1494        }
1495
1496        return $clean;
1497    }
1498
1499    /**
1500     * Convert a PayPal API WP_Error into a REST-appropriate WP_Error with HTTP status.
1501     *
1502     * Preserves the original error code and message, extracting the HTTP status
1503     * from the error data if available.
1504     *
1505     * @param WP_Error $error The API client error.
1506     * @return WP_Error Error with appropriate REST status code.
1507     */
1508    public static function api_error_to_rest_error( WP_Error $error ) {
1509        $data   = $error->get_error_data();
1510        $status = $data['status'] ?? 500;
1511
1512        // Ensure we never return a 0 status (network errors).
1513        if ( 0 === $status || empty( $status ) ) {
1514            $status = 503;
1515        }
1516
1517        return new WP_Error(
1518            $error->get_error_code(),
1519            $error->get_error_message(),
1520            array( 'status' => $status )
1521        );
1522    }
1523}