Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.29% covered (warning)
70.29%
246 / 350
22.73% covered (danger)
22.73%
5 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Memberships
71.10% covered (warning)
71.10%
246 / 346
22.73% covered (danger)
22.73%
5 / 22
239.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
172 / 172
100.00% covered (success)
100.00%
1 / 1
1
 get_status_permission_check
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 can_modify_products_permission_check
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create_products
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
13.57
 list_products
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
9.29
 create_product
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 update_product
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
5.02
 delete_product
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 get_status
30.43% covered (danger)
30.43%
7 / 23
0.00% covered (danger)
0.00%
0 / 1
23.50
 prevent_running_outside_of_wpcom
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 list_products_from_wpcom
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 find_product_from_wpcom
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 create_product_from_wpcom
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 update_product_from_wpcom
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 delete_product_from_wpcom
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 get_payload_for_product
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
7
 validate_tier_field
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 validate_yearly_tier
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 validate_tier_references
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 check_duplicate_tier_references
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
110
 is_wpcom
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php // phpcs:disable WordPress.Files.FileName.InvalidClassFileName
2/**
3 * Memberships: API to communicate with "product" database.
4 *
5 * @package    Jetpack
6 * @since      7.3.0
7 */
8
9use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
10
11if ( ! defined( 'ABSPATH' ) ) {
12    exit( 0 );
13}
14
15/**
16 * Class WPCOM_REST_API_V2_Endpoint_Memberships
17 * This introduces V2 endpoints.
18 *
19 * @phan-constructor-used-for-side-effects
20 */
21class WPCOM_REST_API_V2_Endpoint_Memberships extends WP_REST_Controller {
22
23    use WPCOM_REST_API_Proxy_Request;
24
25    /**
26     * WPCOM_REST_API_V2_Endpoint_Memberships constructor.
27     */
28    public function __construct() {
29        $this->base_api_path                   = 'wpcom';
30        $this->version                         = 'v2';
31        $this->namespace                       = $this->base_api_path . '/' . $this->version;
32        $this->rest_base                       = 'memberships';
33        $this->wpcom_is_wpcom_only_endpoint    = true;
34        $this->wpcom_is_site_specific_endpoint = true;
35        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
36    }
37
38    /**
39     * Called automatically on `rest_api_init()`.
40     */
41    public function register_routes() {
42        register_rest_route(
43            $this->namespace,
44            $this->rest_base . '/status/?',
45            array(
46                array(
47                    'methods'             => WP_REST_Server::READABLE,
48                    'callback'            => array( $this, 'get_status' ),
49                    'permission_callback' => array( $this, 'get_status_permission_check' ),
50                    'args'                => array(
51                        'type'        => array(
52                            'type'              => 'string',
53                            'required'          => false,
54                            'validate_callback' => function ( $param ) {
55                                return in_array( $param, array( 'donation', 'all' ), true );
56                            },
57                        ),
58                        'source'      => array(
59                            'type'              => 'string',
60                            'required'          => false,
61                            'validate_callback' => function ( $param ) {
62                                return in_array(
63                                    $param,
64                                    array(
65                                        'calypso',
66                                        'earn',
67                                        'earn-newsletter',
68                                        'gutenberg',
69                                        'gutenberg-wpcom',
70                                        'launchpad',
71                                        'import-paid-subscribers',
72                                    ),
73                                    true
74                                );
75                            },
76                        ),
77                        'is_editable' => array(
78                            'type'     => 'boolean',
79                            'required' => false,
80                        ),
81                    ),
82                ),
83            )
84        );
85        register_rest_route(
86            $this->namespace,
87            $this->rest_base . '/product/?',
88            array(
89                array(
90                    'methods'             => WP_REST_Server::CREATABLE,
91                    'callback'            => array( $this, 'create_product' ),
92                    'permission_callback' => array( $this, 'get_status_permission_check' ),
93                    'args'                => array(
94                        'title'                   => array(
95                            'type'     => 'string',
96                            'required' => true,
97                        ),
98                        'price'                   => array(
99                            'type'     => 'number',
100                            'required' => true,
101                        ),
102                        'currency'                => array(
103                            'type'     => 'string',
104                            'required' => true,
105                        ),
106                        'interval'                => array(
107                            'type'     => 'string',
108                            'required' => true,
109                        ),
110                        'is_editable'             => array(
111                            'type'     => 'boolean',
112                            'required' => false,
113                        ),
114                        'buyer_can_change_amount' => array(
115                            'type' => 'boolean',
116                        ),
117                        'tier'                    => array(
118                            'type'     => 'integer',
119                            'required' => false,
120                        ),
121                        'description'             => array(
122                            'type'     => 'string',
123                            'required' => false,
124                        ),
125                    ),
126                ),
127            )
128        );
129        register_rest_route(
130            $this->namespace,
131            $this->rest_base . '/products/?',
132            array(
133                array(
134                    'methods'             => WP_REST_Server::CREATABLE,
135                    'callback'            => array( $this, 'create_products' ),
136                    'permission_callback' => array( $this, 'can_modify_products_permission_check' ),
137                    'args'                => array(
138                        'currency'    => array(
139                            'type'     => 'string',
140                            'required' => true,
141                        ),
142                        'type'        => array(
143                            'type'     => 'string',
144                            'required' => true,
145                        ),
146                        'is_editable' => array(
147                            'type'     => 'boolean',
148                            'required' => false,
149                        ),
150                    ),
151                ),
152                array(
153                    'methods'             => WP_REST_Server::READABLE,
154                    'callback'            => array( $this, 'list_products' ),
155                    'permission_callback' => array( $this, 'get_status_permission_check' ),
156                ),
157            )
158        );
159        register_rest_route(
160            $this->namespace,
161            $this->rest_base . '/product/(?P<product_id>[0-9]+)/?',
162            array(
163                array(
164                    'methods'             => WP_REST_Server::EDITABLE,
165                    'callback'            => array( $this, 'update_product' ),
166                    'permission_callback' => array( $this, 'can_modify_products_permission_check' ),
167                    'args'                => array(
168                        'title'                   => array(
169                            'type'     => 'string',
170                            'required' => true,
171                        ),
172                        'price'                   => array(
173                            'type'     => 'number',
174                            'required' => true,
175                        ),
176                        'currency'                => array(
177                            'type'     => 'string',
178                            'required' => true,
179                        ),
180                        'interval'                => array(
181                            'type'     => 'string',
182                            'required' => true,
183                        ),
184                        'is_editable'             => array(
185                            'type'     => 'boolean',
186                            'required' => false,
187                        ),
188                        'buyer_can_change_amount' => array(
189                            'type' => 'boolean',
190                        ),
191                        'tier'                    => array(
192                            'type'     => 'integer',
193                            'required' => false,
194                        ),
195                        'description'             => array(
196                            'type'     => 'string',
197                            'required' => false,
198                        ),
199                    ),
200                ),
201                array(
202                    'methods'             => WP_REST_Server::DELETABLE,
203                    'callback'            => array( $this, 'delete_product' ),
204                    'permission_callback' => array( $this, 'can_modify_products_permission_check' ),
205                    'args'                => array(
206                        'cancel_subscriptions' => array(
207                            'type'     => 'boolean',
208                            'required' => false,
209                        ),
210                    ),
211                ),
212            )
213        );
214    }
215
216    /**
217     * Ensure the user has proper permissions for getting status and listing products
218     *
219     * @return boolean
220     */
221    public function get_status_permission_check() {
222        return current_user_can( 'edit_posts' );
223    }
224
225    /**
226     * Ensure the user has proper permissions to modify products
227     *
228     * @return boolean
229     */
230    public function can_modify_products_permission_check() {
231        return current_user_can( 'manage_options' );
232    }
233
234    /**
235     * Automatically generate products according to type.
236     *
237     * @param object $request - request passed from WP.
238     *
239     * @return array|WP_Error
240     */
241    public function create_products( $request ) {
242        $is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
243
244        if ( $this->is_wpcom() ) {
245            require_lib( 'memberships' );
246            Memberships_Store_Sandbox::get_instance()->init( true );
247
248            $result = Memberships_Product::generate_default_products( get_current_blog_id(), $request['type'], $request['currency'], $is_editable );
249
250            if ( is_wp_error( $result ) ) {
251                $status = 'invalid_param' === $result->get_error_code() ? 400 : 500;
252                return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
253            }
254            return $result;
255        } else {
256            return $this->proxy_request_to_wpcom_as_user( $request, 'products' );
257        }
258
259        return $request;
260    }
261
262    /**
263     * List already-created products.
264     *
265     * @param \WP_REST_Request $request - request passed from WP.
266     *
267     * @return WP_Error|array ['products']
268     */
269    public function list_products( WP_REST_Request $request ) {
270        $is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
271        $type        = $request['type'] ?? null;
272
273        if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
274            require_lib( 'memberships' );
275            require_once JETPACK__PLUGIN_DIR . '/modules/memberships/class-jetpack-memberships.php';
276            try {
277                return array( 'products' => $this->list_products_from_wpcom( $request, $type, $is_editable ) );
278            } catch ( \Exception $e ) {
279                return array( 'error' => $e->getMessage() );
280            }
281        } else {
282
283            return $this->proxy_request_to_wpcom_as_user( $request, 'products' );
284        }
285    }
286
287    /**
288     * Do create a product based on data, or pass request to wpcom.
289     *
290     * @param WP_REST_Request $request - request passed from WP.
291     *
292     * @return array|WP_Error
293     */
294    public function create_product( WP_REST_Request $request ) {
295        $payload = $this->get_payload_for_product( $request );
296
297        if ( is_wp_error( $payload ) ) {
298            return $payload;
299        }
300
301        if ( $this->is_wpcom() ) {
302            require_lib( 'memberships' );
303            try {
304                return $this->create_product_from_wpcom( $payload );
305            } catch ( \Exception $e ) {
306                return array( 'error' => $e->getMessage() );
307            }
308        } else {
309            return $this->proxy_request_to_wpcom_as_user( $request, 'product' );
310        }
311    }
312
313    /**
314     * Update an existing memberships product
315     *
316     * @param \WP_REST_Request $request The request passed from WP.
317     *
318     * @return array|WP_Error
319     */
320    public function update_product( \WP_REST_Request $request ) {
321        $product_id = $request->get_param( 'product_id' );
322        $payload    = $this->get_payload_for_product( $request );
323
324        if ( is_wp_error( $payload ) ) {
325            return $payload;
326        }
327
328        if ( $this->is_wpcom() ) {
329            require_lib( 'memberships' );
330            try {
331                return array( 'product' => $this->update_product_from_wpcom( $product_id, $payload ) );
332            } catch ( \Exception $e ) {
333                return array( 'error' => $e->getMessage() );
334            }
335        } else {
336            return $this->proxy_request_to_wpcom_as_user( $request, "product/$product_id" );
337        }
338    }
339
340    /**
341     * Delete an existing memberships product
342     *
343     * @param \WP_REST_Request $request The request passed from WP.
344     *
345     * @return array|WP_Error
346     */
347    public function delete_product( \WP_REST_Request $request ) {
348        $product_id           = $request->get_param( 'product_id' );
349        $cancel_subscriptions = $request->get_param( 'cancel_subscriptions' );
350        if ( $this->is_wpcom() ) {
351            require_lib( 'memberships' );
352            try {
353                $this->delete_product_from_wpcom( $product_id, $cancel_subscriptions );
354                return array( 'deleted' => true );
355            } catch ( \Exception $e ) {
356                return array( 'error' => $e->getMessage() );
357            }
358        } else {
359            return $this->proxy_request_to_wpcom_as_user( $request, "product/$product_id" );
360        }
361    }
362
363    /**
364     * Get a status of connection for the site. If this is Jetpack, pass the request to wpcom.
365     *
366     * @param \WP_REST_Request $request - request passed from WP.
367     *
368     * @return WP_Error|array ['products','connected_account_id','connect_url']
369     */
370    public function get_status( \WP_REST_Request $request ) {
371        $product_type = $request['type'];
372
373        if ( ! empty( $request['source'] ) ) {
374            $source = sanitize_text_field( wp_unslash( $request['source'] ) );
375        } else {
376            $source = 'gutenberg';
377        }
378
379        $is_editable = ! isset( $request['is_editable'] ) ? null : (bool) $request['is_editable'];
380
381        if ( $this->is_wpcom() ) {
382            require_lib( 'memberships' );
383            Memberships_Store_Sandbox::get_instance()->init( true );
384            $blog_id             = get_current_blog_id();
385            $membership_settings = get_memberships_settings_for_site( $blog_id, $product_type, $is_editable, $source );
386
387            if ( is_wp_error( $membership_settings ) ) {
388                // Get error messages from the $membership_settings.
389                $error_codes    = $membership_settings->get_error_codes();
390                $error_messages = array();
391
392                foreach ( $error_codes as $code ) {
393                    $messages = $membership_settings->get_error_messages( $code );
394                    foreach ( $messages as $message ) {
395                        // Sanitize error message
396                        $error_messages[] = esc_html( $message );
397                    }
398                }
399
400                $error_messages_string = implode( ' ', $error_messages );
401                // translators: %s is a list of error messages.
402                $base_message = __( 'Could not get the membership settings due to the following error(s): %s', 'jetpack' );
403                $full_message = sprintf( $base_message, $error_messages_string );
404
405                return new WP_Error( 'membership_settings_error', $full_message, array( 'status' => 404 ) );
406            }
407
408            return (array) $membership_settings;
409        } else {
410            return $this->proxy_request_to_wpcom_as_user( $request, 'status' );
411        }
412    }
413
414    /**
415     * This function throws an exception if it is run outside of wpcom.
416     *
417     * @return void
418     * @throws \Exception If the function is run outside of WPCOM.
419     */
420    private function prevent_running_outside_of_wpcom() {
421        if ( ! $this->is_wpcom() || ! class_exists( 'Memberships_Product' ) ) {
422            throw new \Exception( 'This function is intended to be run from WPCOM' );
423        }
424    }
425
426    /**
427     * List products via the WPCOM-specific Memberships_Product class.
428     *
429     * @param WP_REST_Request $request The request for this endpoint.
430     * @param ?string         $type The type of the products to list.
431     * @param ?bool           $is_editable If we are looking for editable or non-editable products.
432     * @throws \Exception If blog is not known or if there is an error getting products.
433     * @return array List of products.
434     */
435    private function list_products_from_wpcom( WP_REST_Request $request, $type, $is_editable ) {
436        $this->prevent_running_outside_of_wpcom();
437        Memberships_Store_Sandbox::get_instance()->init( true );
438        $blog_id = $request->get_param( 'blog_id' );
439        if ( is_wp_error( $blog_id ) ) {
440            throw new \Exception( 'Unknown blog' );
441        }
442        $list = Memberships_Product::get_product_list( get_current_blog_id(), $type, $is_editable );
443        if ( is_wp_error( $list ) ) {
444            throw new \Exception( $list->get_error_message() );
445        }
446        return $list;
447    }
448
449    /**
450     * Find a product by product id via the WPCOM-specific Memberships_Product class.
451     *
452     * @param string|int $product_id The ID of the product to be found.
453     * @throws \Exception If there is an error getting the product or if the product was not found.
454     * @return object The found product.
455     */
456    private function find_product_from_wpcom( $product_id ) {
457        $this->prevent_running_outside_of_wpcom();
458        Memberships_Store_Sandbox::get_instance()->init( true );
459        $product = Memberships_Product::get_from_post( get_current_blog_id(), $product_id );
460        if ( is_wp_error( $product ) ) {
461            throw new \Exception( $product->get_error_message() );
462        }
463        if ( ! $product || ! $product instanceof Memberships_Product ) {
464            throw new \Exception( __( 'Product not found.', 'jetpack' ) );
465        }
466        return $product;
467    }
468
469    /**
470     * Create a product via the WPCOM-specific Memberships_Product class.
471     *
472     * @param array $payload The request payload which contains details about the product.
473     * @throws \Exception When the product failed to be created.
474     * @return array The newly created product.
475     */
476    private function create_product_from_wpcom( $payload ) {
477        $this->prevent_running_outside_of_wpcom();
478        Memberships_Store_Sandbox::get_instance()->init( true );
479        $product = Memberships_Product::create( get_current_blog_id(), $payload );
480        if ( is_wp_error( $product ) ) {
481            throw new \Exception( __( 'Creating product has failed.', 'jetpack' ) );
482        }
483        return $product->to_array();
484    }
485
486    /**
487     * Update a product via the WPCOM-specific Memberships_Product class.
488     *
489     * @param string|int $product_id The ID of the product being updated.
490     * @param array      $payload The request payload which contains details about the product.
491     * @throws \Exception When there is a problem updating the product.
492     * @return object The newly updated product.
493     */
494    private function update_product_from_wpcom( $product_id, $payload ) {
495        Memberships_Store_Sandbox::get_instance()->init( true );
496        $product         = $this->find_product_from_wpcom( $product_id ); // prevents running outside of wpcom
497        $updated_product = $product->update( $payload );
498        if ( is_wp_error( $updated_product ) ) {
499            throw new \Exception( $updated_product->get_error_message() );
500        }
501        return $updated_product->to_array();
502    }
503
504    /**
505     * Delete a product via the WPCOM-specific Memberships_Product class.
506     *
507     * @param string|int $product_id The ID of the product being deleted.
508     * @param bool       $cancel_subscriptions Whether to cancel subscriptions to the product as well.
509     * @throws \Exception When there is a problem deleting the product.
510     * @return void
511     */
512    private function delete_product_from_wpcom( $product_id, $cancel_subscriptions = false ) {
513        Memberships_Store_Sandbox::get_instance()->init( true );
514        $product = $this->find_product_from_wpcom( $product_id ); // prevents running outside of wpcom
515        $result  = $product->delete( $cancel_subscriptions ? Memberships_Product::CANCEL_SUBSCRIPTIONS : Memberships_Product::KEEP_SUBSCRIPTIONS );
516        if ( is_wp_error( $result ) ) {
517            throw new \Exception( $result->get_error_message() );
518        }
519    }
520
521    /**
522     * Get a payload for creating or updating products by parsing the request.
523     *
524     * @param WP_REST_Request $request The request for this endpoint, containing the details needed to build the payload.
525     * @return array|WP_Error The built payload or WP_Error on validation failure.
526     */
527    private function get_payload_for_product( WP_REST_Request $request ) {
528        $is_editable             = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
529        $type                    = $request['type'] ?? null;
530        $tier                    = $request['tier'] ?? null;
531        $buyer_can_change_amount = isset( $request['buyer_can_change_amount'] ) && (bool) $request['buyer_can_change_amount'];
532        $interval                = $request['interval'];
533
534        // Validate tier field usage.
535        $tier_validation = $this->validate_tier_field( $request, $tier, $type, $interval );
536        if ( is_wp_error( $tier_validation ) ) {
537            return $tier_validation;
538        }
539
540        $payload = array(
541            'title'                        => $request['title'],
542            'price'                        => $request['price'],
543            'currency'                     => $request['currency'],
544            'buyer_can_change_amount'      => $buyer_can_change_amount,
545            'interval'                     => $interval,
546            'type'                         => $type,
547            'welcome_email_content'        => $request['welcome_email_content'],
548            'subscribe_as_site_subscriber' => $request['subscribe_as_site_subscriber'],
549            'multiple_per_user'            => $request['multiple_per_user'],
550        );
551
552        if ( null !== $tier ) {
553            $payload['tier'] = $tier;
554        }
555
556        // If we pass directly the value "null", it will break the argument validation.
557        if ( null !== $is_editable ) {
558            $payload['is_editable'] = $is_editable;
559        }
560
561        if ( isset( $request['description'] ) ) {
562            $payload['description'] = $request['description'];
563        }
564
565        return $payload;
566    }
567
568    /**
569     * Validate tier field usage for newsletter plans.
570     *
571     * @param WP_REST_Request $request  The request object.
572     * @param string|null     $tier     The tier value to validate.
573     * @param string|null     $type     The product type.
574     * @param string          $interval The product interval.
575     * @return WP_Error|null  Error object if validation fails, null if successful.
576     */
577    private function validate_tier_field( WP_REST_Request $request, $tier, $type, $interval ) {
578        // Only apply tier validation for newsletter plans with type 'tier'.
579        if ( null === $tier || 'tier' !== $type ) {
580            return null;
581        }
582
583        // Monthly plans should not have a tier field.
584        if ( '1 month' === $interval ) {
585            return new WP_Error( 'invalid_tier_usage', __( 'Monthly plans should not have a tier field. The tier field is only used to link yearly plans to their corresponding monthly plans.', 'jetpack' ), array( 'status' => 400 ) );
586        }
587
588        // Yearly plans must have a valid tier that points to a monthly plan.
589        if ( '1 year' === $interval ) {
590            return $this->validate_yearly_tier( $request, $tier );
591        }
592
593        return null;
594    }
595
596    /**
597     * Validate yearly tier requirements.
598     *
599     * @param WP_REST_Request $request The request object.
600     * @param string|int      $tier    The tier value to validate.
601     * @return WP_Error|null  Error object if validation fails, null if successful.
602     */
603    private function validate_yearly_tier( WP_REST_Request $request, $tier ) {
604        if ( ! is_numeric( $tier ) || $tier <= 0 ) {
605            return new WP_Error( 'invalid_tier_id', __( 'Yearly plans must have a valid tier ID that points to an existing monthly plan.', 'jetpack' ), array( 'status' => 400 ) );
606        }
607
608        if ( ! $this->is_wpcom() ) {
609            return null; // Validation will happen on WPCOM side.
610        }
611
612        return $this->validate_tier_references( $request, $tier );
613    }
614
615    /**
616     * Validate that the tier references a valid monthly plan and check for duplicates.
617     *
618     * @param WP_REST_Request $request The request object.
619     * @param string|int      $tier    The tier value to validate.
620     * @return WP_Error|null  Error object if validation fails, null if successful.
621     */
622    private function validate_tier_references( WP_REST_Request $request, $tier ) {
623        require_lib( 'memberships' );
624        Memberships_Store_Sandbox::get_instance()->init( true );
625
626        // Check if the referenced monthly plan exists and is actually a monthly plan.
627        $monthly_plan = Memberships_Product::get_from_post( get_current_blog_id(), $tier );
628        if ( is_wp_error( $monthly_plan ) || ! $monthly_plan ) {
629            return new WP_Error( 'tier_not_found', __( 'The specified tier ID does not correspond to an existing monthly plan.', 'jetpack' ), array( 'status' => 400 ) );
630        }
631
632        $monthly_plan_data = $monthly_plan->to_array();
633        if ( '1 month' !== $monthly_plan_data['interval'] ) {
634            return new WP_Error( 'invalid_tier_interval', __( 'The specified tier ID must point to a monthly plan (1 month interval).', 'jetpack' ), array( 'status' => 400 ) );
635        }
636
637        return $this->check_duplicate_tier_references( $request, $tier );
638    }
639
640    /**
641     * Check for duplicate tier references.
642     *
643     * @param WP_REST_Request $request The request object.
644     * @param string|int      $tier    The tier value to check.
645     * @return WP_Error|null  Error object if duplicate found, null if successful.
646     */
647    private function check_duplicate_tier_references( WP_REST_Request $request, $tier ) {
648        $existing_yearly_plans = Memberships_Product::get_product_list( get_current_blog_id(), 'tier', null, false );
649        if ( is_wp_error( $existing_yearly_plans ) ) {
650            return new WP_Error( 'product_list_error', __( 'Could not retrieve existing products to check for duplicate tier references.', 'jetpack' ), array( 'status' => 500 ) );
651        }
652
653        // Ensure the result is iterable before foreach.
654        if ( ! is_array( $existing_yearly_plans ) && ! $existing_yearly_plans instanceof Traversable ) {
655            return new WP_Error( 'invalid_product_list', __( 'Unexpected error: product list is not iterable.', 'jetpack' ), array( 'status' => 500 ) );
656        }
657
658        foreach ( $existing_yearly_plans as $existing_plan ) {
659            if ( isset( $existing_plan['tier'] ) && (string) $existing_plan['tier'] === (string) $tier && '1 year' === $existing_plan['interval'] ) {
660                // If this is an update, allow it to reference itself.
661                $product_id = $request->get_param( 'product_id' );
662                if ( ! $product_id || (string) $existing_plan['id'] !== (string) $product_id ) {
663                    return new WP_Error( 'duplicate_tier_reference', __( 'Another yearly plan already references this monthly plan. Each monthly plan can only have one corresponding yearly plan.', 'jetpack' ), array( 'status' => 400 ) );
664                }
665            }
666        }
667
668        return null;
669    }
670
671    /**
672     * Returns true if run from WPCOM.
673     *
674     * @return boolean true if run from wpcom, otherwise false.
675     */
676    private function is_wpcom() {
677        return defined( 'IS_WPCOM' ) && IS_WPCOM;
678    }
679}
680
681if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Jetpack::is_connection_ready() ) {
682    wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Memberships' );
683}