PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 All 504 releases
← All changes | _inc/lib/core-api/wpcom-endpoints/memberships.php +193 -131 13.2.4 → 16.3-a.1 View file →
@@ -5,21 +5,31 @@
5 5 * @package Jetpack
6 6 * @since 7.3.0
7 7 */
8 8
9 -use Automattic\Jetpack\Connection\Client;
9 +use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
10 10
11 +if ( ! defined( 'ABSPATH' ) ) {
12 + exit( 0 );
13 +}
14 +
11 15 /**
12 16 * Class WPCOM_REST_API_V2_Endpoint_Memberships
13 17 * This introduces V2 endpoints.
18 + *
19 + * @phan-constructor-used-for-side-effects
14 20 */
15 21 class WPCOM_REST_API_V2_Endpoint_Memberships extends WP_REST_Controller {
16 22
23 + use WPCOM_REST_API_Proxy_Request;
24 +
17 25 /**
18 26 * WPCOM_REST_API_V2_Endpoint_Memberships constructor.
19 27 */
20 28 public function __construct() {
21 - $this->namespace = 'wpcom/v2';
29 + $this->base_api_path = 'wpcom';
30 + $this->version = 'v2';
31 + $this->namespace = $this->base_api_path . '/' . $this->version;
22 32 $this->rest_base = 'memberships';
23 33 $this->wpcom_is_wpcom_only_endpoint = true;
24 34 $this->wpcom_is_site_specific_endpoint = true;
25 35 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
@@ -57,8 +67,9 @@
57 67 'earn-newsletter',
58 68 'gutenberg',
59 69 'gutenberg-wpcom',
60 70 'launchpad',
71 + 'import-paid-subscribers',
61 72 ),
62 73 true
63 74 );
64 75 },
@@ -84,9 +95,9 @@
84 95 'type' => 'string',
85 96 'required' => true,
86 97 ),
87 98 'price' => array(
88 - 'type' => 'float',
99 + 'type' => 'number',
89 100 'required' => true,
90 101 ),
91 102 'currency' => array(
92 103 'type' => 'string',
@@ -106,8 +117,12 @@
106 117 'tier' => array(
107 118 'type' => 'integer',
108 119 'required' => false,
109 120 ),
121 + 'description' => array(
122 + 'type' => 'string',
123 + 'required' => false,
124 + ),
110 125 ),
111 126 ),
112 127 )
113 128 );
@@ -118,8 +133,22 @@
118 133 array(
119 134 'methods' => WP_REST_Server::CREATABLE,
120 135 'callback' => array( $this, 'create_products' ),
121 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 + ),
122 151 ),
123 152 array(
124 153 'methods' => WP_REST_Server::READABLE,
125 154 'callback' => array( $this, 'list_products' ),
@@ -140,9 +169,9 @@
140 169 'type' => 'string',
141 170 'required' => true,
142 171 ),
143 172 'price' => array(
144 - 'type' => 'float',
173 + 'type' => 'number',
145 174 'required' => true,
146 175 ),
147 176 'currency' => array(
148 177 'type' => 'string',
@@ -162,8 +191,12 @@
162 191 'tier' => array(
163 192 'type' => 'integer',
164 193 'required' => false,
165 194 ),
195 + 'description' => array(
196 + 'type' => 'string',
197 + 'required' => false,
198 + ),
166 199 ),
167 200 ),
168 201 array(
169 202 'methods' => WP_REST_Server::DELETABLE,
@@ -168,8 +201,14 @@
168 201 array(
169 202 'methods' => WP_REST_Server::DELETABLE,
170 203 'callback' => array( $this, 'delete_product' ),
171 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 + ),
172 211 ),
173 212 )
174 213 );
175 214 }
@@ -203,8 +242,9 @@
203 242 $is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
204 243
205 244 if ( $this->is_wpcom() ) {
206 245 require_lib( 'memberships' );
246 + Memberships_Store_Sandbox::get_instance()->init( true );
207 247
208 248 $result = Memberships_Product::generate_default_products( get_current_blog_id(), $request['type'], $request['currency'], $is_editable );
209 249
210 250 if ( is_wp_error( $result ) ) {
@@ -212,39 +252,9 @@
212 252 return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
213 253 }
214 254 return $result;
215 255 } else {
216 - $payload = array(
217 - 'type' => $request['type'],
218 - 'currency' => $request['currency'],
219 - );
220 -
221 - // If we pass directly is_editable as null, it would break API argument validation.
222 - if ( null !== $is_editable ) {
223 - $payload['is_editable'] = $is_editable;
224 - }
225 -
226 - $blog_id = Jetpack_Options::get_option( 'id' );
227 - $response = Client::wpcom_json_api_request_as_user(
228 - "/sites/$blog_id/{$this->rest_base}/products",
229 - 'v2',
230 - array(
231 - 'method' => 'POST',
232 - ),
233 - $payload
234 - );
235 - if ( is_wp_error( $response ) ) {
236 - if ( $response->get_error_code() === 'missing_token' ) {
237 - return new WP_Error( 'missing_token', __( 'Please connect your user account to WordPress.com', 'jetpack' ), 404 );
238 - }
239 - return new WP_Error( 'wpcom_connection_error', __( 'Could not connect to WordPress.com', 'jetpack' ), 404 );
240 - }
241 - $data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null;
242 - // If endpoint returned error, we have to detect it.
243 - if ( 200 !== $response['response']['code'] && $data['code'] ) {
244 - return new WP_Error( $data['code'], $data['message'] ? $data['message'] : '', 401 );
245 - }
246 - return $data;
256 + return $this->proxy_request_to_wpcom_as_user( $request, 'products' );
247 257 }
248 258
249 259 return $request;
250 260 }
@@ -256,11 +266,10 @@
256 266 *
257 267 * @return WP_Error|array ['products']
258 268 */
259 269 public function list_products( WP_REST_Request $request ) {
260 - $query = null;
261 270 $is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
262 - $type = isset( $request['type'] ) ? $request['type'] : null;
271 + $type = $request['type'] ?? null;
263 272
264 273 if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
265 274 require_lib( 'memberships' );
266 275 require_once JETPACK__PLUGIN_DIR . '/modules/memberships/class-jetpack-memberships.php';
@@ -269,19 +278,10 @@
269 278 } catch ( \Exception $e ) {
270 279 return array( 'error' => $e->getMessage() );
271 280 }
272 281 } else {
273 - $query_parts = array();
274 - if ( $type !== null ) {
275 - $query_parts[] = 'type=' . $type;
276 - }
277 - if ( $is_editable !== null ) {
278 - $query_parts[] = 'is_editable=' . $is_editable;
279 - }
280 - if ( ! empty( $query_parts ) ) {
281 - $query = '?' . implode( '&', $query_parts );
282 - }
283 - return $this->proxy_request_to_wpcom( "products$query", 'GET' );
282 +
283 + return $this->proxy_request_to_wpcom_as_user( $request, 'products' );
284 284 }
285 285 }
286 286
287 287 /**
@@ -293,8 +293,12 @@
293 293 */
294 294 public function create_product( WP_REST_Request $request ) {
295 295 $payload = $this->get_payload_for_product( $request );
296 296
297 + if ( is_wp_error( $payload ) ) {
298 + return $payload;
299 + }
300 +
297 301 if ( $this->is_wpcom() ) {
298 302 require_lib( 'memberships' );
299 303 try {
300 304 return $this->create_product_from_wpcom( $payload );
@@ -301,9 +305,9 @@
301 305 } catch ( \Exception $e ) {
302 306 return array( 'error' => $e->getMessage() );
303 307 }
304 308 } else {
305 - return $this->proxy_request_to_wpcom( 'product', 'POST', $payload );
309 + return $this->proxy_request_to_wpcom_as_user( $request, 'product' );
306 310 }
307 311 }
308 312
309 313 /**
@@ -316,8 +320,12 @@
316 320 public function update_product( \WP_REST_Request $request ) {
317 321 $product_id = $request->get_param( 'product_id' );
318 322 $payload = $this->get_payload_for_product( $request );
319 323
324 + if ( is_wp_error( $payload ) ) {
325 + return $payload;
326 + }
327 +
320 328 if ( $this->is_wpcom() ) {
321 329 require_lib( 'memberships' );
322 330 try {
323 331 return array( 'product' => $this->update_product_from_wpcom( $product_id, $payload ) );
@@ -324,9 +332,9 @@
324 332 } catch ( \Exception $e ) {
325 333 return array( 'error' => $e->getMessage() );
326 334 }
327 335 } else {
328 - return $this->proxy_request_to_wpcom( "product/$product_id", 'POST', $payload );
336 + return $this->proxy_request_to_wpcom_as_user( $request, "product/$product_id" );
329 337 }
330 338 }
331 339
332 340 /**
@@ -336,19 +344,20 @@
336 344 *
337 345 * @return array|WP_Error
338 346 */
339 347 public function delete_product( \WP_REST_Request $request ) {
340 - $product_id = $request->get_param( 'product_id' );
348 + $product_id = $request->get_param( 'product_id' );
349 + $cancel_subscriptions = $request->get_param( 'cancel_subscriptions' );
341 350 if ( $this->is_wpcom() ) {
342 351 require_lib( 'memberships' );
343 352 try {
344 - $this->delete_product_from_wpcom( $product_id );
353 + $this->delete_product_from_wpcom( $product_id, $cancel_subscriptions );
345 354 return array( 'deleted' => true );
346 355 } catch ( \Exception $e ) {
347 356 return array( 'error' => $e->getMessage() );
348 357 }
349 358 } else {
350 - return $this->proxy_request_to_wpcom( "product/$product_id", 'DELETE' );
359 + return $this->proxy_request_to_wpcom_as_user( $request, "product/$product_id" );
351 360 }
352 361 }
353 362
354 363 /**
@@ -370,8 +379,9 @@
370 379 $is_editable = ! isset( $request['is_editable'] ) ? null : (bool) $request['is_editable'];
371 380
372 381 if ( $this->is_wpcom() ) {
373 382 require_lib( 'memberships' );
383 + Memberships_Store_Sandbox::get_instance()->init( true );
374 384 $blog_id = get_current_blog_id();
375 385 $membership_settings = get_memberships_settings_for_site( $blog_id, $product_type, $is_editable, $source );
376 386
377 387 if ( is_wp_error( $membership_settings ) ) {
@@ -396,82 +406,13 @@
396 406 }
397 407
398 408 return (array) $membership_settings;
399 409 } else {
400 - $payload = array(
401 - 'type' => $request['type'],
402 - 'source' => $source,
403 - );
404 -
405 - // If we pass directly is_editable as null, it would break API argument validation.
406 - // This also needs to be converted to int because boolean false is ignored by add_query_arg.
407 - if ( null !== $is_editable ) {
408 - $payload['is_editable'] = (int) $is_editable;
409 - }
410 -
411 - $blog_id = Jetpack_Options::get_option( 'id' );
412 - $path = "/sites/$blog_id/{$this->rest_base}/status";
413 - if ( $product_type ) {
414 - $path = add_query_arg(
415 - $payload,
416 - $path
417 - );
418 - }
419 - $response = Client::wpcom_json_api_request_as_user( $path, 'v2' );
420 - if ( is_wp_error( $response ) ) {
421 - if ( $response->get_error_code() === 'missing_token' ) {
422 - return new WP_Error( 'missing_token', __( 'Please connect your user account to WordPress.com', 'jetpack' ), 404 );
423 - }
424 - return new WP_Error( 'wpcom_connection_error', __( 'Could not connect to WordPress.com', 'jetpack' ), 404 );
425 - }
426 - $data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null;
427 - if ( 200 !== $response['response']['code'] && $data['code'] && $data['message'] ) {
428 - return new WP_Error( $data['code'], $data['message'], 401 );
429 - }
430 - return $data;
410 + return $this->proxy_request_to_wpcom_as_user( $request, 'status' );
431 411 }
432 412 }
433 413
434 414 /**
435 - * Proxy a request to WPCOM, look for errors and return a response or a WP_Error.
436 - *
437 - * @param string $uri Whatever would go at the end of the url after /sites/$blog_id/$this->rest_base/. This is usually `product`, `products`, or `product/$product_id`.
438 - * @param string $method The HTTP method being used.
439 - * @param array|null $payload An optional payload to be sent with the request.
440 - * @return string The response from WPCOM
441 - */
442 - private function proxy_request_to_wpcom( $uri, $method, $payload = null ) {
443 - // get blog id
444 - $blog_id = Jetpack_Options::get_option( 'id' );
445 -
446 - // proxy request to wpcom
447 - $response = Client::wpcom_json_api_request_as_user(
448 - "/sites/$blog_id/{$this->rest_base}/$uri",
449 - 'v2',
450 - array(
451 - 'method' => strtoupper( $method ),
452 - ),
453 - $payload
454 - );
455 - if ( is_wp_error( $response ) ) {
456 - if ( $response->get_error_code() === 'missing_token' ) {
457 - return new WP_Error( 'missing_token', __( 'Please connect your user account to WordPress.com', 'jetpack' ), 404 );
458 - }
459 - return new WP_Error( 'wpcom_connection_error', __( 'Could not connect to WordPress.com', 'jetpack' ), 404 );
460 - }
461 -
462 - // decode response
463 - $data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null;
464 - // If endpoint returned error, we have to detect it.
465 - if ( 200 !== $response['response']['code'] && $data['code'] && $data['message'] ) {
466 - return new WP_Error( $data['code'], $data['message'], 401 );
467 - }
468 -
469 - // return response
470 - return $data;
471 - }
472 -
473 - /**
474 415 * This function throws an exception if it is run outside of wpcom.
475 416 *
476 417 * @return void
477 418 * @throws \Exception If the function is run outside of WPCOM.
@@ -486,14 +427,15 @@
486 427 * List products via the WPCOM-specific Memberships_Product class.
487 428 *
488 429 * @param WP_REST_Request $request The request for this endpoint.
489 430 * @param ?string $type The type of the products to list.
490 - * @param ?string $is_editable This string will be interpreted as a bool to determine if we are looking for editable or non-editable products.
431 + * @param ?bool $is_editable If we are looking for editable or non-editable products.
491 432 * @throws \Exception If blog is not known or if there is an error getting products.
492 433 * @return array List of products.
493 434 */
494 435 private function list_products_from_wpcom( WP_REST_Request $request, $type, $is_editable ) {
495 436 $this->prevent_running_outside_of_wpcom();
437 + Memberships_Store_Sandbox::get_instance()->init( true );
496 438 $blog_id = $request->get_param( 'blog_id' );
497 439 if ( is_wp_error( $blog_id ) ) {
498 440 throw new \Exception( 'Unknown blog' );
499 441 }
@@ -512,8 +454,9 @@
512 454 * @return object The found product.
513 455 */
514 456 private function find_product_from_wpcom( $product_id ) {
515 457 $this->prevent_running_outside_of_wpcom();
458 + Memberships_Store_Sandbox::get_instance()->init( true );
516 459 $product = Memberships_Product::get_from_post( get_current_blog_id(), $product_id );
517 460 if ( is_wp_error( $product ) ) {
518 461 throw new \Exception( $product->get_error_message() );
519 462 }
@@ -527,12 +470,13 @@
527 470 * Create a product via the WPCOM-specific Memberships_Product class.
528 471 *
529 472 * @param array $payload The request payload which contains details about the product.
530 473 * @throws \Exception When the product failed to be created.
531 - * @return object The newly created product.
474 + * @return array The newly created product.
532 475 */
533 476 private function create_product_from_wpcom( $payload ) {
534 477 $this->prevent_running_outside_of_wpcom();
478 + Memberships_Store_Sandbox::get_instance()->init( true );
535 479 $product = Memberships_Product::create( get_current_blog_id(), $payload );
536 480 if ( is_wp_error( $product ) ) {
537 481 throw new \Exception( __( 'Creating product has failed.', 'jetpack' ) );
538 482 }
@@ -547,8 +491,9 @@
547 491 * @throws \Exception When there is a problem updating the product.
548 492 * @return object The newly updated product.
549 493 */
550 494 private function update_product_from_wpcom( $product_id, $payload ) {
495 + Memberships_Store_Sandbox::get_instance()->init( true );
551 496 $product = $this->find_product_from_wpcom( $product_id ); // prevents running outside of wpcom
552 497 $updated_product = $product->update( $payload );
553 498 if ( is_wp_error( $updated_product ) ) {
554 499 throw new \Exception( $updated_product->get_error_message() );
@@ -559,14 +504,16 @@
559 504 /**
560 505 * Delete a product via the WPCOM-specific Memberships_Product class.
561 506 *
562 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.
563 509 * @throws \Exception When there is a problem deleting the product.
564 510 * @return void
565 511 */
566 - private function delete_product_from_wpcom( $product_id ) {
512 + private function delete_product_from_wpcom( $product_id, $cancel_subscriptions = false ) {
513 + Memberships_Store_Sandbox::get_instance()->init( true );
567 514 $product = $this->find_product_from_wpcom( $product_id ); // prevents running outside of wpcom
568 - $result = $product->delete();
515 + $result = $product->delete( $cancel_subscriptions ? Memberships_Product::CANCEL_SUBSCRIPTIONS : Memberships_Product::KEEP_SUBSCRIPTIONS );
569 516 if ( is_wp_error( $result ) ) {
570 517 throw new \Exception( $result->get_error_message() );
571 518 }
572 519 }
@@ -574,22 +521,29 @@
574 521 /**
575 522 * Get a payload for creating or updating products by parsing the request.
576 523 *
577 524 * @param WP_REST_Request $request The request for this endpoint, containing the details needed to build the payload.
578 - * @return array The built payload.
525 + * @return array|WP_Error The built payload or WP_Error on validation failure.
579 526 */
580 527 private function get_payload_for_product( WP_REST_Request $request ) {
581 528 $is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
582 - $type = isset( $request['type'] ) ? $request['type'] : null;
583 - $tier = isset( $request['tier'] ) ? $request['tier'] : null;
529 + $type = $request['type'] ?? null;
530 + $tier = $request['tier'] ?? null;
584 531 $buyer_can_change_amount = isset( $request['buyer_can_change_amount'] ) && (bool) $request['buyer_can_change_amount'];
532 + $interval = $request['interval'];
585 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 +
586 540 $payload = array(
587 541 'title' => $request['title'],
588 542 'price' => $request['price'],
589 543 'currency' => $request['currency'],
590 544 'buyer_can_change_amount' => $buyer_can_change_amount,
591 - 'interval' => $request['interval'],
545 + 'interval' => $interval,
592 546 'type' => $type,
593 547 'welcome_email_content' => $request['welcome_email_content'],
594 548 'subscribe_as_site_subscriber' => $request['subscribe_as_site_subscriber'],
595 549 'multiple_per_user' => $request['multiple_per_user'],
@@ -602,9 +556,117 @@
602 556 // If we pass directly the value "null", it will break the argument validation.
603 557 if ( null !== $is_editable ) {
604 558 $payload['is_editable'] = $is_editable;
605 559 }
560 +
561 + if ( isset( $request['description'] ) ) {
562 + $payload['description'] = $request['description'];
563 + }
564 +
606 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;
607 669 }
608 670
609 671 /**
610 672 * Returns true if run from WPCOM.