Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.07% covered (success)
96.07%
757 / 788
58.33% covered (warning)
58.33%
14 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_REST_Controller
96.31% covered (success)
96.31%
757 / 786
58.33% covered (warning)
58.33%
14 / 24
126
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
236 / 236
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
81.63% covered (warning)
81.63%
40 / 49
0.00% covered (danger)
0.00%
0 / 1
8.40
 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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 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
50.00% covered (danger)
50.00%
10 / 20
0.00% covered (danger)
0.00%
0 / 1
2.50
 handle_merchant_status
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 handle_create_button
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 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%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 handle_update_button
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 handle_delete_button
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
6
 validate_button_request
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
11
 get_variant_currency
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 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.33% covered (success)
97.33%
73 / 75
0.00% covered (danger)
0.00%
0 / 1
39
 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 (exchange auth code for credentials).
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                        'auth_code'             => array(
139                            'required'          => true,
140                            'type'              => 'string',
141                            'sanitize_callback' => array( __CLASS__, 'sanitize_oauth_value' ),
142                            'validate_callback' => array( __CLASS__, 'validate_non_empty_string' ),
143                            'description'       => __( 'Authorization code from PayPal onboarding callback.', 'jetpack-paypal-payments' ),
144                        ),
145                        'shared_id'             => array(
146                            'required'          => true,
147                            'type'              => 'string',
148                            'sanitize_callback' => array( __CLASS__, 'sanitize_oauth_value' ),
149                            'validate_callback' => array( __CLASS__, 'validate_non_empty_string' ),
150                            'description'       => __( 'Shared ID from PayPal onboarding callback.', 'jetpack-paypal-payments' ),
151                        ),
152                        'merchant_id_in_paypal' => array(
153                            'required'          => false,
154                            'type'              => 'string',
155                            'default'           => '',
156                            'sanitize_callback' => 'sanitize_text_field',
157                            'description'       => __( 'Merchant PayPal payer ID from onboarding callback.', 'jetpack-paypal-payments' ),
158                        ),
159                    ),
160                ),
161            )
162        );
163
164        // Partner Referrals onboarding — check merchant status.
165        register_rest_route(
166            self::REST_NAMESPACE,
167            self::ROUTE_BASE . '/onboarding/status',
168            array(
169                array(
170                    'methods'             => WP_REST_Server::READABLE,
171                    'callback'            => array( __CLASS__, 'handle_merchant_status' ),
172                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
173                ),
174            )
175        );
176
177        // Connection status.
178        register_rest_route(
179            self::REST_NAMESPACE,
180            self::ROUTE_BASE . '/connection',
181            array(
182                array(
183                    'methods'             => WP_REST_Server::READABLE,
184                    'callback'            => array( __CLASS__, 'handle_connection_status' ),
185                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
186                ),
187            )
188        );
189
190        // Disconnect.
191        register_rest_route(
192            self::REST_NAMESPACE,
193            self::ROUTE_BASE . '/disconnect',
194            array(
195                array(
196                    'methods'             => WP_REST_Server::CREATABLE,
197                    'callback'            => array( __CLASS__, 'handle_disconnect' ),
198                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
199                ),
200            )
201        );
202
203        // --- Button CRUD endpoints ---
204
205        // Create a payment resource (button/link).
206        register_rest_route(
207            self::REST_NAMESPACE,
208            self::ROUTE_BASE . '/buttons',
209            array(
210                array(
211                    'methods'             => WP_REST_Server::CREATABLE,
212                    'callback'            => array( __CLASS__, 'handle_create_button' ),
213                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
214                    'args'                => self::get_button_create_args(),
215                ),
216            )
217        );
218
219        // List payment resources.
220        register_rest_route(
221            self::REST_NAMESPACE,
222            self::ROUTE_BASE . '/buttons',
223            array(
224                array(
225                    'methods'             => WP_REST_Server::READABLE,
226                    'callback'            => array( __CLASS__, 'handle_list_buttons' ),
227                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
228                    'args'                => array(
229                        'page_size'  => array(
230                            'required'    => false,
231                            'type'        => 'integer',
232                            // PayPal has no server-side search, so callers filter
233                            // client-side. Fetch a whole page by default.
234                            'default'     => 100,
235                            'minimum'     => 1,
236                            'maximum'     => 100,
237                            'description' => __( 'Payment resources per page.', 'jetpack-paypal-payments' ),
238                        ),
239                        'page_token' => array(
240                            'required'          => false,
241                            'type'              => 'string',
242                            'default'           => '',
243                            'sanitize_callback' => 'sanitize_text_field',
244                            'description'       => __( 'Cursor from the previous page\'s next link.', 'jetpack-paypal-payments' ),
245                        ),
246                    ),
247                ),
248            )
249        );
250
251        // Get a single payment resource.
252        register_rest_route(
253            self::REST_NAMESPACE,
254            self::ROUTE_BASE . '/buttons/(?P<resource_id>PLB-[A-Za-z0-9]+)',
255            array(
256                array(
257                    'methods'             => WP_REST_Server::READABLE,
258                    'callback'            => array( __CLASS__, 'handle_get_button' ),
259                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
260                ),
261            )
262        );
263
264        // Update a payment resource (full replacement via PUT).
265        register_rest_route(
266            self::REST_NAMESPACE,
267            self::ROUTE_BASE . '/buttons/(?P<resource_id>PLB-[A-Za-z0-9]+)',
268            array(
269                array(
270                    'methods'             => 'PUT',
271                    'callback'            => array( __CLASS__, 'handle_update_button' ),
272                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
273                    'args'                => self::get_button_create_args(),
274                ),
275            )
276        );
277
278        // Delete a payment resource.
279        register_rest_route(
280            self::REST_NAMESPACE,
281            self::ROUTE_BASE . '/buttons/(?P<resource_id>PLB-[A-Za-z0-9]+)',
282            array(
283                array(
284                    'methods'             => WP_REST_Server::DELETABLE,
285                    'callback'            => array( __CLASS__, 'handle_delete_button' ),
286                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
287                    'args'                => array(
288                        'unused_only' => array(
289                            'type'        => 'boolean',
290                            'default'     => false,
291                            'description' => __( 'Keep the payment link when another published post still embeds it.', 'jetpack-paypal-payments' ),
292                        ),
293                        'post_id'     => array(
294                            'type'        => 'integer',
295                            'default'     => 0,
296                            'description' => __( 'The post being saved, which does not count as still embedding the link.', 'jetpack-paypal-payments' ),
297                        ),
298                    ),
299                ),
300            )
301        );
302
303        // Environment switch.
304        register_rest_route(
305            self::REST_NAMESPACE,
306            self::ROUTE_BASE . '/environment',
307            array(
308                array(
309                    'methods'             => WP_REST_Server::CREATABLE,
310                    'callback'            => array( __CLASS__, 'handle_set_environment' ),
311                    'permission_callback' => array( __CLASS__, 'manage_options_permission_check' ),
312                    'args'                => array(
313                        'environment' => array(
314                            'required'          => true,
315                            'type'              => 'string',
316                            'enum'              => array( 'sandbox', 'production' ),
317                            'sanitize_callback' => 'sanitize_text_field',
318                            'description'       => __( 'PayPal environment: sandbox or production.', 'jetpack-paypal-payments' ),
319                        ),
320                    ),
321                ),
322            )
323        );
324    }
325
326    /**
327     * Permission check: current user can manage_options.
328     *
329     * @return bool|WP_Error True if permitted, WP_Error otherwise.
330     */
331    public static function manage_options_permission_check() {
332        if ( ! current_user_can( 'manage_options' ) ) {
333            return new WP_Error(
334                'rest_forbidden',
335                __( 'You do not have permission to manage PayPal settings.', 'jetpack-paypal-payments' ),
336                array( 'status' => 403 )
337            );
338        }
339
340        return true;
341    }
342
343    /**
344     * Sanitize an OAuth credential or token value.
345     *
346     * Uses trim() instead of sanitize_text_field() to preserve valid OAuth
347     * characters (+, /, =) that sanitize_text_field() would strip. These
348     * values are never rendered to HTML — they go into encrypted storage
349     * and HTTP Authorization headers.
350     *
351     * @param string $value The value to sanitize.
352     * @return string The trimmed value.
353     */
354    public static function sanitize_oauth_value( $value ) {
355        return trim( wp_unslash( $value ) );
356    }
357
358    /**
359     * Validate that a string parameter is non-empty.
360     *
361     * Used as a REST validate_callback, so the incoming value may be of any type;
362     * anything that is not a non-empty string is rejected.
363     *
364     * @param mixed           $value   The value to validate.
365     * @param WP_REST_Request $request The REST request.
366     * @param string          $param   The parameter name.
367     * @return bool|WP_Error True if valid, WP_Error otherwise.
368     */
369    public static function validate_non_empty_string( $value, $request, $param ) {
370        if ( ! is_string( $value ) || '' === trim( $value ) ) {
371            return new WP_Error(
372                'rest_invalid_param',
373                sprintf(
374                    /* translators: %s: parameter name */
375                    __( 'The %s parameter must be a non-empty string.', 'jetpack-paypal-payments' ),
376                    $param
377                ),
378                array( 'status' => 400 )
379            );
380        }
381
382        return true;
383    }
384
385    /**
386     * Handle POST /paypal/connect -- store credentials and validate via token exchange.
387     *
388     * @param WP_REST_Request $request The REST request.
389     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
390     */
391    public static function handle_connect( WP_REST_Request $request ) {
392        $client_id     = $request->get_param( 'client_id' );
393        $client_secret = $request->get_param( 'client_secret' );
394        $environment   = $request->get_param( 'environment' );
395
396        // Save previous environment so we can restore it if connect fails.
397        $previous_environment = PayPal_OAuth::get_environment();
398
399        // Set environment first so token exchange uses the right base URL.
400        PayPal_OAuth::set_environment( $environment );
401
402        // Store the credentials.
403        $stored = PayPal_OAuth::store_credentials( $client_id, $client_secret );
404        if ( is_wp_error( $stored ) ) {
405            PayPal_OAuth::set_environment( $previous_environment );
406            return self::api_error_to_rest_error( $stored );
407        }
408        if ( ! $stored ) {
409            PayPal_OAuth::set_environment( $previous_environment );
410            return new WP_Error(
411                'paypal_credentials_storage_failed',
412                __( 'Failed to store PayPal credentials. Please ensure your WordPress installation supports encryption (OpenSSL extension).', 'jetpack-paypal-payments' ),
413                array( 'status' => 500 )
414            );
415        }
416
417        // Validate by attempting a token exchange.
418        $validation = PayPal_OAuth::validate_credentials();
419        if ( is_wp_error( $validation ) ) {
420            // Credentials are invalid — remove them and restore previous environment.
421            PayPal_OAuth::delete_credentials();
422            PayPal_OAuth::set_environment( $previous_environment );
423
424            // Provide a user-friendly message based on the error type.
425            $error_data = $validation->get_error_data();
426            $status     = isset( $error_data['status'] ) ? (int) $error_data['status'] : 401;
427
428            if ( 401 === $status ) {
429                $message = __( 'The Client ID or Client Secret is incorrect. Please double-check your credentials in the PayPal Developer Dashboard.', 'jetpack-paypal-payments' );
430            } else {
431                $message = __( 'Could not connect to PayPal. Please check your credentials and try again.', 'jetpack-paypal-payments' );
432            }
433
434            return new WP_Error(
435                'paypal_credentials_invalid',
436                $message,
437                array( 'status' => $status )
438            );
439        }
440
441        // Validate that the account has Payment Links & Buttons API access.
442        $api_access = PayPal_OAuth::validate_api_access();
443        if ( is_wp_error( $api_access ) ) {
444            // Credentials are valid but the app lacks the required scope — remove them and restore previous environment.
445            PayPal_OAuth::delete_credentials();
446            PayPal_OAuth::set_environment( $previous_environment );
447
448            $error_data = $api_access->get_error_data();
449            $status     = isset( $error_data['status'] ) ? (int) $error_data['status'] : 403;
450
451            return new WP_Error(
452                $api_access->get_error_code(),
453                $api_access->get_error_message(),
454                array( 'status' => $status )
455            );
456        }
457
458        return new WP_REST_Response(
459            array(
460                'connected'   => true,
461                'environment' => PayPal_OAuth::get_environment(),
462                'message'     => __( 'PayPal account connected successfully.', 'jetpack-paypal-payments' ),
463            ),
464            200
465        );
466    }
467
468    /**
469     * Handle GET /paypal/connection -- return current connection status.
470     *
471     * @param WP_REST_Request $request The REST request.
472     * @return WP_REST_Response Response with connection status.
473     */
474    public static function handle_connection_status( WP_REST_Request $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
475        $status = PayPal_OAuth::get_connection_status();
476
477        // The editor appends this to payment links it copies to the clipboard,
478        // so those links are attributed the same way the rendered button is.
479        $status['partner_attribution_id'] = PayPal_Payment_Buttons::PAYPAL_PARTNER_ATTRIBUTION_ID;
480
481        return new WP_REST_Response( $status, 200 );
482    }
483
484    /**
485     * Handle POST /paypal/disconnect -- remove credentials and cached token.
486     *
487     * @param WP_REST_Request $request The REST request.
488     * @return WP_REST_Response Response confirming disconnection.
489     */
490    public static function handle_disconnect( WP_REST_Request $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
491        PayPal_OAuth::disconnect();
492        PayPal_Partner_Onboarding::cleanup();
493
494        return new WP_REST_Response(
495            array(
496                'connected' => false,
497                'message'   => __( 'PayPal account disconnected.', 'jetpack-paypal-payments' ),
498            ),
499            200
500        );
501    }
502
503    /**
504     * Handle POST /paypal/environment -- switch between sandbox and production.
505     *
506     * @param WP_REST_Request $request The REST request.
507     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
508     */
509    public static function handle_set_environment( WP_REST_Request $request ) {
510        $environment = $request->get_param( 'environment' );
511
512        PayPal_OAuth::set_environment( $environment );
513
514        return new WP_REST_Response(
515            array(
516                'environment' => PayPal_OAuth::get_environment(),
517                'message'     => sprintf(
518                    /* translators: %s: environment name (sandbox or production) */
519                    __( 'PayPal environment set to %s. Cached token has been cleared.', 'jetpack-paypal-payments' ),
520                    $environment
521                ),
522            ),
523            200
524        );
525    }
526
527    // --- Partner Referrals onboarding handlers ---
528
529    /**
530     * Handle POST /paypal/onboarding/signup-link -- generate a Partner Referrals signup URL.
531     *
532     * @param WP_REST_Request $request The REST request.
533     * @return WP_REST_Response|WP_Error Response with action_url on success.
534     */
535    public static function handle_generate_signup_link( WP_REST_Request $request ) {
536        $return_url  = $request->get_param( 'return_url' );
537        $environment = $request->get_param( 'environment' );
538
539        // Set environment so the referral uses the right PayPal base URL.
540        PayPal_OAuth::set_environment( $environment );
541
542        $result = PayPal_Partner_Onboarding::generate_signup_link( $return_url, $environment );
543
544        if ( is_wp_error( $result ) ) {
545            return self::api_error_to_rest_error( $result );
546        }
547
548        return new WP_REST_Response(
549            array(
550                'action_url'  => $result['action_url'],
551                'referral_id' => $result['referral_id'],
552                'environment' => $environment,
553            ),
554            200
555        );
556    }
557
558    /**
559     * Handle POST /paypal/onboarding/complete -- exchange auth code for credentials.
560     *
561     * Called by the block editor after the merchant completes the PayPal
562     * mini-browser onboarding flow.
563     *
564     * @param WP_REST_Request $request The REST request.
565     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
566     */
567    public static function handle_onboarding_complete( WP_REST_Request $request ) {
568        $auth_code             = $request->get_param( 'auth_code' );
569        $shared_id             = $request->get_param( 'shared_id' );
570        $merchant_id_in_paypal = $request->get_param( 'merchant_id_in_paypal' );
571
572        $result = PayPal_Partner_Onboarding::complete_onboarding(
573            $auth_code,
574            $shared_id,
575            $merchant_id_in_paypal
576        );
577
578        if ( is_wp_error( $result ) ) {
579            return self::api_error_to_rest_error( $result );
580        }
581
582        return new WP_REST_Response(
583            array(
584                'connected'   => true,
585                'environment' => PayPal_OAuth::get_environment(),
586                'merchant_id' => PayPal_Partner_Onboarding::get_merchant_id(),
587                'method'      => 'partner_referrals',
588                'message'     => __( 'PayPal account connected successfully via Connect with PayPal.', 'jetpack-paypal-payments' ),
589            ),
590            200
591        );
592    }
593
594    /**
595     * Handle GET /paypal/onboarding/status -- check merchant integration status.
596     *
597     * @param WP_REST_Request $request The REST request.
598     * @return WP_REST_Response|WP_Error Response with merchant status.
599     */
600    public static function handle_merchant_status( WP_REST_Request $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
601        $status = PayPal_Partner_Onboarding::check_merchant_status();
602
603        if ( is_wp_error( $status ) ) {
604            return self::api_error_to_rest_error( $status );
605        }
606
607        return new WP_REST_Response( $status, 200 );
608    }
609
610    // --- Button CRUD handlers ---
611
612    /**
613     * Handle POST /paypal/buttons -- create a payment resource via the PayPal API.
614     *
615     * Validates input via PayPal_Attribute_Mapper before calling the API.
616     *
617     * @param WP_REST_Request $request The REST request.
618     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
619     */
620    public static function handle_create_button( WP_REST_Request $request ) {
621        // Server-side validation before API call.
622        $validation = self::validate_button_request( $request );
623        if ( is_wp_error( $validation ) ) {
624            return $validation;
625        }
626
627        $resource_data = self::build_resource_data( $request );
628
629        $result = PayPal_API_Client::create_resource( $resource_data );
630
631        if ( is_wp_error( $result ) ) {
632            return self::api_error_to_rest_error( $result );
633        }
634
635        return new WP_REST_Response( $result, 201 );
636    }
637
638    /**
639     * Handle GET /paypal/buttons -- list payment resources.
640     *
641     * @param WP_REST_Request $request The REST request.
642     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
643     */
644    public static function handle_list_buttons( WP_REST_Request $request ) {
645        $page_size  = $request->get_param( 'page_size' );
646        $page_token = $request->get_param( 'page_token' );
647
648        $result = PayPal_API_Client::list_resources( $page_size, $page_token );
649
650        if ( is_wp_error( $result ) ) {
651            return self::api_error_to_rest_error( $result );
652        }
653
654        return new WP_REST_Response( $result, 200 );
655    }
656
657    /**
658     * Handle GET /paypal/buttons/{resource_id} -- get a single payment resource.
659     *
660     * @param WP_REST_Request $request The REST request.
661     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
662     */
663    public static function handle_get_button( WP_REST_Request $request ) {
664        $resource_id = $request->get_param( 'resource_id' );
665
666        $result = PayPal_API_Client::get_resource( $resource_id );
667
668        if ( is_wp_error( $result ) ) {
669            return self::api_error_to_rest_error( $result );
670        }
671
672        // The editor reads a payment back to line its block up with what PayPal
673        // holds, so hand it the block shape alongside the raw resource.
674        $result['attributes'] = PayPal_Attribute_Mapper::api_response_to_attributes( $result );
675
676        return new WP_REST_Response( $result, 200 );
677    }
678
679    /**
680     * Handle PUT /paypal/buttons/{resource_id} -- update a payment resource (full replacement).
681     *
682     * Validates input via PayPal_Attribute_Mapper before calling the API.
683     *
684     * @param WP_REST_Request $request The REST request.
685     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
686     */
687    public static function handle_update_button( WP_REST_Request $request ) {
688        // Server-side validation before API call.
689        $validation = self::validate_button_request( $request );
690        if ( is_wp_error( $validation ) ) {
691            return $validation;
692        }
693
694        $resource_id   = $request->get_param( 'resource_id' );
695        $resource_data = self::build_resource_data( $request );
696
697        $result = PayPal_API_Client::update_resource( $resource_id, $resource_data );
698
699        if ( is_wp_error( $result ) ) {
700            return self::api_error_to_rest_error( $result );
701        }
702
703        return new WP_REST_Response( $result, 200 );
704    }
705
706    /**
707     * Handle DELETE /paypal/buttons/{resource_id} -- delete a payment resource.
708     *
709     * @param WP_REST_Request $request The REST request.
710     * @return WP_REST_Response|WP_Error Response on success, WP_Error on failure.
711     */
712    public static function handle_delete_button( WP_REST_Request $request ) {
713        $resource_id = $request->get_param( 'resource_id' );
714
715        // The editor deletes a link when a post is saved without its block, but a
716        // link is shared by every block that points at it, on any post.
717        if ( $request->get_param( 'unused_only' ) ) {
718            $embeds = PayPal_Admin_Page::count_published_embeds( absint( $request->get_param( 'post_id' ) ) );
719            $others = $embeds[ $resource_id ] ?? 0;
720            if ( $others > 0 ) {
721                return new WP_REST_Response(
722                    array(
723                        'deleted'     => false,
724                        'resource_id' => $resource_id,
725                        'message'     => sprintf(
726                            /* translators: %d: number of published posts */
727                            _n(
728                                'Kept the payment link: %d other published post still uses it.',
729                                'Kept the payment link: %d other published posts still use it.',
730                                $others,
731                                'jetpack-paypal-payments'
732                            ),
733                            $others
734                        ),
735                    ),
736                    200
737                );
738            }
739        }
740
741        $result = PayPal_API_Client::delete_resource( $resource_id );
742
743        if ( is_wp_error( $result ) ) {
744            // If the resource is already gone (404), treat as success.
745            $error_data = $result->get_error_data();
746            if ( isset( $error_data['status'] ) && 404 === (int) $error_data['status'] ) {
747                return new WP_REST_Response(
748                    array(
749                        'deleted'     => true,
750                        'resource_id' => $resource_id,
751                        'message'     => __( 'Payment resource was already deleted from PayPal.', 'jetpack-paypal-payments' ),
752                    ),
753                    200
754                );
755            }
756
757            return self::api_error_to_rest_error( $result );
758        }
759
760        return new WP_REST_Response(
761            array(
762                'deleted'     => true,
763                'resource_id' => $resource_id,
764                'message'     => __( 'Payment resource deleted successfully.', 'jetpack-paypal-payments' ),
765            ),
766            200
767        );
768    }
769
770    // --- Shared helpers ---
771
772    /**
773     * Validate a button create/update request using PayPal_Attribute_Mapper.
774     *
775     * Extracts the line_items from the request, maps them to block attributes,
776     * and runs full validation before the API call is made. This catches
777     * issues that client-side validation might miss.
778     *
779     * @param WP_REST_Request $request The REST request.
780     * @return true|WP_Error True if valid, WP_Error on validation failure.
781     */
782    private static function validate_button_request( WP_REST_Request $request ) {
783        $line_items = $request->get_param( 'line_items' );
784
785        if ( empty( $line_items ) || ! is_array( $line_items ) ) {
786            return new WP_Error(
787                'missing_line_items',
788                __( 'At least one line item is required.', 'jetpack-paypal-payments' ),
789                array( 'status' => 400 )
790            );
791        }
792
793        // Extract the first line item into attribute-style format for validation.
794        $first_item = $line_items[0];
795        $attributes = array(
796            'productName' => $first_item['name'] ?? '',
797        );
798
799        if ( isset( $first_item['unit_amount'] ) && is_array( $first_item['unit_amount'] ) ) {
800            $attributes['price']        = $first_item['unit_amount']['value'] ?? '';
801            $attributes['currencyCode'] = $first_item['unit_amount']['currency_code'] ?? 'USD';
802        }
803
804        // Per-option prices stand in for the product-level price, so the
805        // validator needs them to know a missing `unit_amount` is legitimate.
806        if ( ! empty( $first_item['variants'] ) && is_array( $first_item['variants'] ) ) {
807            $attributes['variantsEnabled'] = true;
808            $attributes['variants']        = $first_item['variants'];
809
810            if ( ! isset( $attributes['currencyCode'] ) ) {
811                $attributes['currencyCode'] = self::get_variant_currency( $first_item['variants'] );
812            }
813        }
814
815        if ( ! empty( $first_item['description'] ) ) {
816            $attributes['productDescription'] = $first_item['description'];
817        }
818
819        $return_url = $request->get_param( 'return_url' );
820        if ( ! empty( $return_url ) ) {
821            $attributes['returnUrl'] = $return_url;
822        }
823
824        $validation = PayPal_Attribute_Mapper::validate_attributes( $attributes );
825
826        if ( is_wp_error( $validation ) ) {
827            // Re-wrap with status for REST response.
828            $data = $validation->get_error_data();
829            return new WP_Error(
830                $validation->get_error_code(),
831                $validation->get_error_message(),
832                array( 'status' => $data['status'] ?? 400 )
833            );
834        }
835
836        return true;
837    }
838
839    /**
840     * Read the currency from the first priced variant option.
841     *
842     * Used when a line item has no product-level `unit_amount` because its
843     * options carry their own prices.
844     *
845     * @since $$next-version$$
846     *
847     * @param array $variants Variants structure from the request.
848     * @return string The currency code, defaulting to USD.
849     */
850    private static function get_variant_currency( $variants ) {
851        foreach ( ( $variants['dimensions'] ?? array() ) as $dimension ) {
852            foreach ( ( $dimension['options'] ?? array() ) as $option ) {
853                if ( '' !== trim( (string) ( $option['unit_amount']['value'] ?? '' ) ) ) {
854                    return $option['unit_amount']['currency_code'] ?? 'USD';
855                }
856            }
857        }
858
859        return 'USD';
860    }
861
862    /**
863     * Get REST API arg definitions for button create/update endpoints.
864     *
865     * Defines the line_items schema matching PayPal's Pay Links & Buttons API.
866     * Phase 1 supports BUY_NOW type with LINK integration mode.
867     *
868     * @return array REST API args definition.
869     */
870    private static function get_button_create_args() {
871        // Shipping, handling and discounts take the same fields.
872        $amount_list = array(
873            'type'     => 'array',
874            'required' => false,
875            'items'    => array(
876                'type'       => 'object',
877                'properties' => array(
878                    'type'                  => array( 'type' => 'string' ),
879                    'value'                 => array( 'type' => 'string' ),
880                    'additional_unit_value' => array( 'type' => 'string' ),
881                ),
882            ),
883        );
884
885        return array(
886            'name'             => array(
887                'required'          => false,
888                'type'              => 'string',
889                'sanitize_callback' => 'sanitize_text_field',
890                'description'       => __( 'Display name for the payment resource.', 'jetpack-paypal-payments' ),
891            ),
892            'type'             => array(
893                'required'          => false,
894                'type'              => 'string',
895                'default'           => 'BUY_NOW',
896                'enum'              => array( 'BUY_NOW' ),
897                'sanitize_callback' => 'sanitize_text_field',
898                'description'       => __( 'Payment type. Currently only BUY_NOW is supported.', 'jetpack-paypal-payments' ),
899            ),
900            'integration_mode' => array(
901                'required'          => false,
902                'type'              => 'string',
903                'default'           => 'LINK',
904                'enum'              => array( 'LINK', 'BUTTON' ),
905                'sanitize_callback' => 'sanitize_text_field',
906                'description'       => __( 'Integration mode. LINK returns a payment URL.', 'jetpack-paypal-payments' ),
907            ),
908            'reusable'         => array(
909                'required'          => false,
910                'type'              => 'string',
911                'default'           => 'MULTIPLE',
912                'enum'              => array( 'MULTIPLE', 'SINGLE' ),
913                'sanitize_callback' => 'sanitize_text_field',
914                'description'       => __( 'Whether the link can be used multiple times.', 'jetpack-paypal-payments' ),
915            ),
916            'return_url'       => array(
917                'required'          => false,
918                'type'              => 'string',
919                'format'            => 'uri',
920                'sanitize_callback' => 'esc_url_raw',
921                'description'       => __( 'URL to redirect the buyer to after payment.', 'jetpack-paypal-payments' ),
922            ),
923            'line_items'       => array(
924                'required'    => true,
925                'type'        => 'array',
926                'minItems'    => 1,
927                'description' => __( 'Line items for the payment resource.', 'jetpack-paypal-payments' ),
928                'items'       => array(
929                    'type'       => 'object',
930                    'properties' => array(
931                        'name'                     => array(
932                            'type'     => 'string',
933                            'required' => true,
934                        ),
935                        'description'              => array(
936                            'type'     => 'string',
937                            'required' => false,
938                        ),
939                        // Shown on the PayPal checkout. The sanitizer keeps it only when HTTPS.
940                        'image_url'                => array(
941                            'type'     => 'string',
942                            'required' => false,
943                        ),
944                        // Not required: omitted when the product options carry
945                        // their own per-option prices.
946                        'unit_amount'              => array(
947                            'type'       => 'object',
948                            'required'   => false,
949                            'properties' => array(
950                                'currency_code' => array(
951                                    'type'     => 'string',
952                                    'required' => true,
953                                ),
954                                'value'         => array(
955                                    'type'     => 'string',
956                                    'required' => true,
957                                ),
958                            ),
959                        ),
960                        'quantity'                 => array(
961                            'type'     => 'string',
962                            'required' => false,
963                            'default'  => '1',
964                        ),
965                        'variants'                 => array(
966                            'type'       => 'object',
967                            'required'   => false,
968                            'properties' => array(
969                                'dimensions' => array(
970                                    'type'  => 'array',
971                                    'items' => array(
972                                        'type'       => 'object',
973                                        'properties' => array(
974                                            'name'    => array( 'type' => 'string' ),
975                                            'primary' => array( 'type' => 'boolean' ),
976                                            'options' => array(
977                                                'type'  => 'array',
978                                                'items' => array(
979                                                    'type' => 'object',
980                                                    'properties' => array(
981                                                        'label'       => array( 'type' => 'string' ),
982                                                        'unit_amount' => array(
983                                                            'type'       => 'object',
984                                                            'properties' => array(
985                                                                'currency_code' => array( 'type' => 'string' ),
986                                                                'value'         => array( 'type' => 'string' ),
987                                                            ),
988                                                        ),
989                                                    ),
990                                                ),
991                                            ),
992                                        ),
993                                    ),
994                                ),
995                            ),
996                        ),
997                        'adjustable_quantity'      => array(
998                            'type'       => 'object',
999                            'required'   => false,
1000                            'properties' => array(
1001                                'maximum' => array( 'type' => 'integer' ),
1002                            ),
1003                        ),
1004                        'customer_notes'           => array(
1005                            'type'     => 'array',
1006                            'required' => false,
1007                            'items'    => array(
1008                                'type'       => 'object',
1009                                'properties' => array(
1010                                    'label'    => array( 'type' => 'string' ),
1011                                    'required' => array( 'type' => 'boolean' ),
1012                                ),
1013                            ),
1014                        ),
1015                        'taxes'                    => array(
1016                            'type'     => 'array',
1017                            'required' => false,
1018                            'items'    => array(
1019                                'type'       => 'object',
1020                                'properties' => array(
1021                                    'name'  => array( 'type' => 'string' ),
1022                                    'type'  => array(
1023                                        'type' => 'string',
1024                                        'enum' => array( 'PERCENTAGE', 'PREFERENCE', 'FLAT' ),
1025                                    ),
1026                                    'value' => array( 'type' => 'string' ),
1027                                ),
1028                            ),
1029                        ),
1030                        // Set outside the form, but a PUT replaces the whole resource,
1031                        // so the editor sends them back.
1032                        'product_id'               => array(
1033                            'type'     => 'string',
1034                            'required' => false,
1035                        ),
1036                        'shipping'                 => $amount_list,
1037                        'handling'                 => $amount_list,
1038                        'discounts'                => $amount_list,
1039                        'collect_shipping_address' => array(
1040                            'type'     => 'boolean',
1041                            'required' => false,
1042                        ),
1043                    ),
1044                ),
1045            ),
1046        );
1047    }
1048
1049    /**
1050     * Build the resource data array from a REST request for PayPal API submission.
1051     *
1052     * Extracts and sanitizes relevant parameters, stripping null/empty optional values
1053     * so only populated fields are sent to PayPal.
1054     *
1055     * @param WP_REST_Request $request The incoming REST request.
1056     * @return array The sanitized resource data ready for the PayPal API.
1057     */
1058    private static function build_resource_data( WP_REST_Request $request ) {
1059        $data = array(
1060            'type'             => $request->get_param( 'type' ),
1061            'integration_mode' => $request->get_param( 'integration_mode' ),
1062            'reusable'         => $request->get_param( 'reusable' ),
1063            'line_items'       => $request->get_param( 'line_items' ),
1064        );
1065
1066        // Sanitize line_items deeply.
1067        if ( is_array( $data['line_items'] ) ) {
1068            $data['line_items'] = self::sanitize_line_items( $data['line_items'] );
1069        }
1070
1071        // Add optional fields only when present.
1072        $name = $request->get_param( 'name' );
1073        if ( ! empty( $name ) ) {
1074            $data['name'] = $name;
1075        }
1076
1077        $return_url = $request->get_param( 'return_url' );
1078        if ( ! empty( $return_url ) ) {
1079            $data['return_url'] = $return_url;
1080        }
1081
1082        return $data;
1083    }
1084
1085    /**
1086     * Sanitize line items array for PayPal API submission.
1087     *
1088     * Applies sanitize_text_field to string values and keeps an image URL only when
1089     * it is HTTPS.
1090     *
1091     * @param array $line_items Raw line items from the REST request.
1092     * @return array Sanitized line items.
1093     */
1094    private static function sanitize_line_items( $line_items ) {
1095        $sanitized = array();
1096
1097        foreach ( $line_items as $item ) {
1098            $clean_item = array(
1099                'name' => isset( $item['name'] ) ? sanitize_text_field( $item['name'] ) : '',
1100            );
1101
1102            // Variants (product options with optional per-option pricing).
1103            if ( ! empty( $item['variants'] ) && is_array( $item['variants'] ) ) {
1104                $clean_item['variants'] = self::sanitize_variants( $item['variants'] );
1105            }
1106
1107            // PayPal rejects a line item that specifies `unit_amount` at both the
1108            // product level and the variant level, so per-option prices replace
1109            // the product-level price rather than sitting alongside it.
1110            if ( ! PayPal_Attribute_Mapper::variants_have_pricing( $clean_item['variants'] ?? null ) ) {
1111                $clean_item['unit_amount'] = array(
1112                    'currency_code' => isset( $item['unit_amount']['currency_code'] )
1113                        ? sanitize_text_field( $item['unit_amount']['currency_code'] )
1114                        : 'USD',
1115                    'value'         => isset( $item['unit_amount']['value'] )
1116                        ? sanitize_text_field( $item['unit_amount']['value'] )
1117                        : '0.00',
1118                );
1119            }
1120
1121            // Optional fields.
1122            if ( ! empty( $item['description'] ) ) {
1123                $clean_item['description'] = sanitize_text_field( $item['description'] );
1124            }
1125
1126            // PayPal fetches the image itself, so anything but a public HTTPS URL is
1127            // dropped rather than rejected: an http:// site can still save its button.
1128            if ( ! empty( $item['image_url'] ) ) {
1129                $image_url = esc_url_raw( (string) $item['image_url'], array( 'https' ) );
1130                if ( 0 === strpos( $image_url, 'https://' ) ) {
1131                    $clean_item['image_url'] = $image_url;
1132                }
1133            }
1134            if ( ! empty( $item['quantity'] ) ) {
1135                $clean_item['quantity'] = (string) max( 1, absint( $item['quantity'] ) );
1136            }
1137
1138            // Adjustable quantity configuration.
1139            if ( ! empty( $item['adjustable_quantity'] ) && is_array( $item['adjustable_quantity'] ) ) {
1140                $clean_item['adjustable_quantity'] = array(
1141                    'maximum' => isset( $item['adjustable_quantity']['maximum'] )
1142                        ? absint( $item['adjustable_quantity']['maximum'] )
1143                        : 10,
1144                );
1145            }
1146
1147            // Customer notes (custom checkout fields).
1148            if ( ! empty( $item['customer_notes'] ) && is_array( $item['customer_notes'] ) ) {
1149                $clean_notes = array();
1150                foreach ( $item['customer_notes'] as $note ) {
1151                    if ( is_array( $note ) && ! empty( $note['label'] ) ) {
1152                        $clean_notes[] = array(
1153                            'label'    => sanitize_text_field( $note['label'] ),
1154                            'required' => ! empty( $note['required'] ),
1155                        );
1156                    }
1157                }
1158                if ( ! empty( $clean_notes ) ) {
1159                    $clean_item['customer_notes'] = $clean_notes;
1160                }
1161            }
1162
1163            // Tax configuration.
1164            if ( ! empty( $item['taxes'] ) && is_array( $item['taxes'] ) ) {
1165                $clean_taxes = array();
1166                $valid_types = array( 'PERCENTAGE', 'PREFERENCE', 'FLAT' );
1167                foreach ( $item['taxes'] as $tax ) {
1168                    // No name: PayPal labels the tax itself, and requiring one here
1169                    // threw the whole tax away.
1170                    if ( is_array( $tax ) ) {
1171                        $tax_type = isset( $tax['type'] ) ? sanitize_text_field( $tax['type'] ) : 'PERCENTAGE';
1172                        if ( ! in_array( $tax_type, $valid_types, true ) ) {
1173                            $tax_type = 'PERCENTAGE';
1174                        }
1175
1176                        if ( 'PREFERENCE' === $tax_type ) {
1177                            // The rate comes from the merchant's PayPal profile.
1178                            $tax_value = 'PROFILE';
1179                        } elseif ( 'FLAT' === $tax_type ) {
1180                            // A flat tax is an amount, not a rate - keep a string as sent
1181                            // so '1.50' does not become 1.5. PayPal validates it itself.
1182                            $tax_value = trim( sanitize_text_field( (string) ( $tax['value'] ?? '0' ) ) );
1183                            if ( '' === $tax_value ) {
1184                                $tax_value = '0';
1185                            }
1186                        } else {
1187                            $tax_value = (string) max( 0, floatval( $tax['value'] ?? 0 ) );
1188                        }
1189
1190                        $clean_tax = array(
1191                            'type'  => $tax_type,
1192                            'value' => $tax_value,
1193                        );
1194
1195                        // name is optional, so only send a real one - an empty string
1196                        // is not a name.
1197                        if ( ! empty( $tax['name'] ) ) {
1198                            $clean_tax['name'] = sanitize_text_field( $tax['name'] );
1199                        }
1200
1201                        $clean_taxes[] = $clean_tax;
1202                    }
1203                }
1204                if ( ! empty( $clean_taxes ) ) {
1205                    $clean_item['taxes'] = $clean_taxes;
1206                }
1207            }
1208
1209            // Copied back from the payment by the editor. Drop one here and Update
1210            // deletes it at PayPal.
1211            if ( isset( $item['product_id'] ) && '' !== $item['product_id'] ) {
1212                $clean_item['product_id'] = sanitize_text_field( $item['product_id'] );
1213            }
1214            foreach ( array( 'shipping', 'handling', 'discounts' ) as $field ) {
1215                if ( ! empty( $item[ $field ] ) && is_array( $item[ $field ] ) ) {
1216                    $clean_amounts = self::sanitize_amount_list( $item[ $field ] );
1217                    if ( ! empty( $clean_amounts ) ) {
1218                        $clean_item[ $field ] = $clean_amounts;
1219                    }
1220                }
1221            }
1222
1223            // Send it even when off - omit it and PayPal turns address collection back on.
1224            if ( isset( $item['collect_shipping_address'] ) ) {
1225                $clean_item['collect_shipping_address'] = (bool) $item['collect_shipping_address'];
1226            }
1227
1228            $sanitized[] = $clean_item;
1229        }
1230
1231        return $sanitized;
1232    }
1233
1234    /**
1235     * Sanitize a shipping, handling or discount list for PayPal API submission.
1236     *
1237     * All three take a type, a value, and for per-unit shipping a rate for each
1238     * extra unit. The type passes straight through: these come back off the payment,
1239     * so anything PayPal accepted must survive the round trip.
1240     *
1241     * @param array $amounts Raw entries from the REST request.
1242     * @return array Sanitized entries.
1243     */
1244    private static function sanitize_amount_list( $amounts ) {
1245        $clean = array();
1246
1247        foreach ( $amounts as $amount ) {
1248            // A zero is a legitimate amount, and in a zero-decimal currency it is
1249            // written "0", which empty() would throw away.
1250            if ( ! is_array( $amount ) || ! isset( $amount['value'] ) || '' === (string) $amount['value'] ) {
1251                continue;
1252            }
1253
1254            $clean_amount = array(
1255                'type'  => isset( $amount['type'] ) ? sanitize_text_field( $amount['type'] ) : 'FLAT',
1256                'value' => sanitize_text_field( (string) $amount['value'] ),
1257            );
1258
1259            if ( isset( $amount['additional_unit_value'] ) && '' !== (string) $amount['additional_unit_value'] ) {
1260                $clean_amount['additional_unit_value'] = sanitize_text_field( (string) $amount['additional_unit_value'] );
1261            }
1262
1263            $clean[] = $clean_amount;
1264        }
1265
1266        return $clean;
1267    }
1268
1269    /**
1270     * Sanitize variant dimensions and options.
1271     *
1272     * @param array $variants Raw variants data from the REST request.
1273     * @return array Sanitized variants.
1274     */
1275    private static function sanitize_variants( $variants ) {
1276        $clean = array();
1277
1278        if ( ! empty( $variants['dimensions'] ) && is_array( $variants['dimensions'] ) ) {
1279            $clean_dims = array();
1280            foreach ( $variants['dimensions'] as $dimension ) {
1281                if ( ! is_array( $dimension ) ) {
1282                    continue;
1283                }
1284                $clean_dim = array(
1285                    'name'    => isset( $dimension['name'] ) ? sanitize_text_field( $dimension['name'] ) : '',
1286                    'primary' => ! empty( $dimension['primary'] ),
1287                );
1288
1289                if ( ! empty( $dimension['options'] ) && is_array( $dimension['options'] ) ) {
1290                    $clean_opts = array();
1291                    foreach ( $dimension['options'] as $option ) {
1292                        if ( ! is_array( $option ) ) {
1293                            continue;
1294                        }
1295                        $clean_opt = array(
1296                            'label' => isset( $option['label'] ) ? sanitize_text_field( $option['label'] ) : '',
1297                        );
1298                        // Per-option pricing, on the primary dimension only. The
1299                        // editor sends an empty value for un-priced options —
1300                        // that's "no price", not a price of zero, and passing it
1301                        // through would make PayPal see variant-level pricing.
1302                        if ( $clean_dim['primary'] && ! empty( $option['unit_amount'] ) && is_array( $option['unit_amount'] ) ) {
1303                            $value = trim( sanitize_text_field( (string) ( $option['unit_amount']['value'] ?? '' ) ) );
1304                            if ( '' !== $value ) {
1305                                $clean_opt['unit_amount'] = array(
1306                                    'currency_code' => isset( $option['unit_amount']['currency_code'] )
1307                                        ? sanitize_text_field( $option['unit_amount']['currency_code'] )
1308                                        : 'USD',
1309                                    'value'         => $value,
1310                                );
1311                            }
1312                        }
1313                        $clean_opts[] = $clean_opt;
1314                    }
1315                    $clean_dim['options'] = $clean_opts;
1316                }
1317
1318                $clean_dims[] = $clean_dim;
1319            }
1320            $clean['dimensions'] = $clean_dims;
1321        }
1322
1323        return $clean;
1324    }
1325
1326    /**
1327     * Convert a PayPal API WP_Error into a REST-appropriate WP_Error with HTTP status.
1328     *
1329     * Preserves the original error code and message, extracting the HTTP status
1330     * from the error data if available.
1331     *
1332     * @param WP_Error $error The API client error.
1333     * @return WP_Error Error with appropriate REST status code.
1334     */
1335    private static function api_error_to_rest_error( WP_Error $error ) {
1336        $data   = $error->get_error_data();
1337        $status = $data['status'] ?? 500;
1338
1339        // Ensure we never return a 0 status (network errors).
1340        if ( 0 === $status || empty( $status ) ) {
1341            $status = 503;
1342        }
1343
1344        return new WP_Error(
1345            $error->get_error_code(),
1346            $error->get_error_message(),
1347            array( 'status' => $status )
1348        );
1349    }
1350}