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