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 +422 -260 12.5.2 → 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' ) );
@@ -30,9 +40,9 @@
30 40 */
31 41 public function register_routes() {
32 42 register_rest_route(
33 43 $this->namespace,
34 - $this->rest_base . '/status',
44 + $this->rest_base . '/status/?',
35 45 array(
36 46 array(
37 47 'methods' => WP_REST_Server::READABLE,
38 48 'callback' => array( $this, 'get_status' ),
@@ -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 },
@@ -72,9 +83,9 @@
72 83 )
73 84 );
74 85 register_rest_route(
75 86 $this->namespace,
76 - $this->rest_base . '/product',
87 + $this->rest_base . '/product/?',
77 88 array(
78 89 array(
79 90 'methods' => WP_REST_Server::CREATABLE,
80 91 'callback' => array( $this, 'create_product' ),
@@ -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',
@@ -102,8 +113,16 @@
102 113 ),
103 114 'buyer_can_change_amount' => array(
104 115 'type' => 'boolean',
105 116 ),
117 + 'tier' => array(
118 + 'type' => 'integer',
119 + 'required' => false,
120 + ),
121 + 'description' => array(
122 + 'type' => 'string',
123 + 'required' => false,
124 + ),
106 125 ),
107 126 ),
108 127 )
109 128 );
@@ -108,14 +127,28 @@
108 127 )
109 128 );
110 129 register_rest_route(
111 130 $this->namespace,
112 - $this->rest_base . '/products',
131 + $this->rest_base . '/products/?',
113 132 array(
114 133 array(
115 134 'methods' => WP_REST_Server::CREATABLE,
116 135 'callback' => array( $this, 'create_products' ),
117 - 'permission_callback' => array( $this, 'get_status_permission_check' ),
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 + ),
118 151 ),
119 152 array(
120 153 'methods' => WP_REST_Server::READABLE,
121 154 'callback' => array( $this, 'list_products' ),
@@ -124,14 +157,14 @@
124 157 )
125 158 );
126 159 register_rest_route(
127 160 $this->namespace,
128 - $this->rest_base . '/product/(?P<product_id>[0-9]+)',
161 + $this->rest_base . '/product/(?P<product_id>[0-9]+)/?',
129 162 array(
130 163 array(
131 164 'methods' => WP_REST_Server::EDITABLE,
132 165 'callback' => array( $this, 'update_product' ),
133 - 'permission_callback' => array( $this, 'get_status_permission_check' ),
166 + 'permission_callback' => array( $this, 'can_modify_products_permission_check' ),
134 167 'args' => array(
135 168 'title' => array(
136 169 'type' => 'string',
137 170 'required' => true,
@@ -136,9 +169,9 @@
136 169 'type' => 'string',
137 170 'required' => true,
138 171 ),
139 172 'price' => array(
140 - 'type' => 'float',
173 + 'type' => 'number',
141 174 'required' => true,
142 175 ),
143 176 'currency' => array(
144 177 'type' => 'string',
@@ -154,14 +187,28 @@
154 187 ),
155 188 'buyer_can_change_amount' => array(
156 189 'type' => 'boolean',
157 190 ),
191 + 'tier' => array(
192 + 'type' => 'integer',
193 + 'required' => false,
194 + ),
195 + 'description' => array(
196 + 'type' => 'string',
197 + 'required' => false,
198 + ),
158 199 ),
159 200 ),
160 201 array(
161 202 'methods' => WP_REST_Server::DELETABLE,
162 203 'callback' => array( $this, 'delete_product' ),
163 - 'permission_callback' => array( $this, 'get_status_permission_check' ),
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 + ),
164 211 ),
165 212 )
166 213 );
167 214 }
@@ -166,8 +213,54 @@
166 213 );
167 214 }
168 215
169 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 + /**
170 263 * List already-created products.
171 264 *
172 265 * @param \WP_REST_Request $request - request passed from WP.
173 266 *
@@ -174,194 +267,283 @@
174 267 * @return WP_Error|array ['products']
175 268 */
176 269 public function list_products( WP_REST_Request $request ) {
177 270 $is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
178 - $type = isset( $request['type'] ) ? $request['type'] : null;
271 + $type = $request['type'] ?? null;
179 272
180 273 if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
181 274 require_lib( 'memberships' );
182 275 require_once JETPACK__PLUGIN_DIR . '/modules/memberships/class-jetpack-memberships.php';
183 - $blog_id = $request->get_param( 'blog_id' );
184 - if ( is_wp_error( $blog_id ) ) {
185 - return array( 'error' => 'Unknown blog' );
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() );
186 280 }
187 - $list = Memberships_Product::get_product_list( get_current_blog_id(), $type, $is_editable );
188 - if ( is_wp_error( $list ) ) {
189 - return $list;
190 - }
191 - return array( 'products' => $list );
192 281 } else {
193 - $blog_id = Jetpack_Options::get_option( 'id' );
194 - $response = Client::wpcom_json_api_request_as_user(
195 - "/sites/$blog_id/{$this->rest_base}/products",
196 - 'v2',
197 - array(
198 - 'method' => 'GET',
199 - )
200 - );
201 - if ( is_wp_error( $response ) ) {
202 - if ( $response->get_error_code() === 'missing_token' ) {
203 - return new WP_Error( 'missing_token', __( 'Please connect your user account to WordPress.com', 'jetpack' ), 404 );
204 - }
205 - return new WP_Error( 'wpcom_connection_error', __( 'Could not connect to WordPress.com', 'jetpack' ), 404 );
206 - }
207 - $data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null;
208 - // If endpoint returned error, we have to detect it.
209 - if ( 200 !== $response['response']['code'] && $data['code'] && $data['message'] ) {
210 - return new WP_Error( $data['code'], $data['message'], 401 );
211 - }
212 - return $data;
282 +
283 + return $this->proxy_request_to_wpcom_as_user( $request, 'products' );
213 284 }
214 285 }
215 286
216 287 /**
217 - * Ensure the user has proper permissions
288 + * Do create a product based on data, or pass request to wpcom.
218 289 *
219 - * @return boolean
290 + * @param WP_REST_Request $request - request passed from WP.
291 + *
292 + * @return array|WP_Error
220 293 */
221 - public function get_status_permission_check() {
222 - return current_user_can( 'edit_posts' );
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 + }
223 311 }
224 312
225 313 /**
226 - * Do create a product based on data, or pass request to wpcom.
314 + * Update an existing memberships product
227 315 *
228 - * @param WP_REST_Request $request - request passed from WP.
316 + * @param \WP_REST_Request $request The request passed from WP.
229 317 *
230 318 * @return array|WP_Error
231 319 */
232 - public function create_product( WP_REST_Request $request ) {
233 - $is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
234 - $type = isset( $request['type'] ) ? $request['type'] : null;
235 - $buyer_can_change_amount = isset( $request['buyer_can_change_amount'] ) && (bool) $request['buyer_can_change_amount'];
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 );
236 323
237 - $payload = array(
238 - 'title' => $request['title'],
239 - 'price' => $request['price'],
240 - 'currency' => $request['currency'],
241 - 'buyer_can_change_amount' => $buyer_can_change_amount,
242 - 'interval' => $request['interval'],
243 - 'type' => $type,
244 - 'welcome_email_content' => $request['welcome_email_content'],
245 - 'subscribe_as_site_subscriber' => $request['subscribe_as_site_subscriber'],
246 - 'multiple_per_user' => $request['multiple_per_user'],
247 - );
324 + if ( is_wp_error( $payload ) ) {
325 + return $payload;
326 + }
248 327
249 - // If we pass directly the value "null", it will break the argument validation.
250 - if ( null !== $is_editable ) {
251 - $payload['is_editable'] = $is_editable;
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" );
252 337 }
338 + }
253 339
254 - if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
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() ) {
255 351 require_lib( 'memberships' );
256 - $product = Memberships_Product::create( get_current_blog_id(), $payload );
257 - if ( is_wp_error( $product ) ) {
258 - return new WP_Error( $product->get_error_code(), __( 'Creating product has failed.', 'jetpack' ) );
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() );
259 357 }
260 - return $product->to_array();
261 358 } else {
262 - $blog_id = Jetpack_Options::get_option( 'id' );
263 - $response = Client::wpcom_json_api_request_as_user(
264 - "/sites/$blog_id/{$this->rest_base}/product",
265 - 'v2',
266 - array(
267 - 'method' => 'POST',
268 - ),
269 - $payload
270 - );
271 - if ( is_wp_error( $response ) ) {
272 - if ( $response->get_error_code() === 'missing_token' ) {
273 - return new WP_Error( 'missing_token', __( 'Please connect your user account to WordPress.com', 'jetpack' ), 404 );
274 - }
275 - return new WP_Error( 'wpcom_connection_error', __( 'Could not connect to WordPress.com', 'jetpack' ), 404 );
276 - }
277 - $data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null;
278 - // If endpoint returned error, we have to detect it.
279 - if ( 200 !== $response['response']['code'] && $data['code'] && $data['message'] ) {
280 - return new WP_Error( $data['code'], $data['message'], 401 );
281 - }
282 - return $data;
359 + return $this->proxy_request_to_wpcom_as_user( $request, "product/$product_id" );
283 360 }
284 -
285 - return $request;
286 361 }
287 362
288 363 /**
289 - * Automatically generate products according to type.
364 + * Get a status of connection for the site. If this is Jetpack, pass the request to wpcom.
290 365 *
291 - * @param object $request - request passed from WP.
366 + * @param \WP_REST_Request $request - request passed from WP.
292 367 *
293 - * @return array|WP_Error
368 + * @return WP_Error|array ['products','connected_account_id','connect_url']
294 369 */
295 - public function create_products( $request ) {
296 - $is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
370 + public function get_status( \WP_REST_Request $request ) {
371 + $product_type = $request['type'];
297 372
298 - if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
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() ) {
299 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 );
300 386
301 - $result = Memberships_Product::generate_default_products( get_current_blog_id(), $request['type'], $request['currency'], $is_editable );
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();
302 391
303 - if ( is_wp_error( $result ) ) {
304 - $status = 'invalid_param' === $result->get_error_code() ? 400 : 500;
305 - return new WP_Error( $result->get_error_code(), $result->get_error_message(), array( 'status' => $status ) );
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 ) );
306 406 }
307 - return $result;
407 +
408 + return (array) $membership_settings;
308 409 } else {
309 - $payload = array(
310 - 'type' => $request['type'],
311 - 'currency' => $request['currency'],
312 - );
410 + return $this->proxy_request_to_wpcom_as_user( $request, 'status' );
411 + }
412 + }
313 413
314 - // If we pass directly is_editable as null, it would break API argument validation.
315 - if ( null !== $is_editable ) {
316 - $payload['is_editable'] = $is_editable;
317 - }
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 + }
318 425
319 - $blog_id = Jetpack_Options::get_option( 'id' );
320 - $response = Client::wpcom_json_api_request_as_user(
321 - "/sites/$blog_id/{$this->rest_base}/products",
322 - 'v2',
323 - array(
324 - 'method' => 'POST',
325 - ),
326 - $payload
327 - );
328 - if ( is_wp_error( $response ) ) {
329 - if ( $response->get_error_code() === 'missing_token' ) {
330 - return new WP_Error( 'missing_token', __( 'Please connect your user account to WordPress.com', 'jetpack' ), 404 );
331 - }
332 - return new WP_Error( 'wpcom_connection_error', __( 'Could not connect to WordPress.com', 'jetpack' ), 404 );
333 - }
334 - $data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null;
335 - // If endpoint returned error, we have to detect it.
336 - if ( 200 !== $response['response']['code'] && $data['code'] ) {
337 - return new WP_Error( $data['code'], $data['message'] ? $data['message'] : '', 401 );
338 - }
339 - return $data;
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' );
340 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 + }
341 448
342 - return $request;
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;
343 467 }
344 468
345 469 /**
346 - * Update an existing memberships product
470 + * Create a product via the WPCOM-specific Memberships_Product class.
347 471 *
348 - * @param \WP_REST_Request $request The request passed from WP.
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.
349 488 *
350 - * @return array|WP_Error
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.
351 493 */
352 - public function update_product( \WP_REST_Request $request ) {
353 - $product_id = $request->get_param( 'product_id' );
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 ) {
354 528 $is_editable = isset( $request['is_editable'] ) ? (bool) $request['is_editable'] : null;
355 - $type = isset( $request['type'] ) ? $request['type'] : null;
529 + $type = $request['type'] ?? null;
530 + $tier = $request['tier'] ?? null;
356 531 $buyer_can_change_amount = isset( $request['buyer_can_change_amount'] ) && (bool) $request['buyer_can_change_amount'];
532 + $interval = $request['interval'];
357 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 +
358 540 $payload = array(
359 541 'title' => $request['title'],
360 542 'price' => $request['price'],
361 543 'currency' => $request['currency'],
362 544 'buyer_can_change_amount' => $buyer_can_change_amount,
363 - 'interval' => $request['interval'],
545 + 'interval' => $interval,
364 546 'type' => $type,
365 547 'welcome_email_content' => $request['welcome_email_content'],
366 548 'subscribe_as_site_subscriber' => $request['subscribe_as_site_subscriber'],
367 549 'multiple_per_user' => $request['multiple_per_user'],
@@ -366,154 +548,134 @@
366 548 'subscribe_as_site_subscriber' => $request['subscribe_as_site_subscriber'],
367 549 'multiple_per_user' => $request['multiple_per_user'],
368 550 );
369 551
370 - // If we pass directly is_editable as null, it would break API argument validation.
552 + if ( null !== $tier ) {
553 + $payload['tier'] = $tier;
554 + }
555 +
556 + // If we pass directly the value "null", it will break the argument validation.
371 557 if ( null !== $is_editable ) {
372 558 $payload['is_editable'] = $is_editable;
373 559 }
374 560
375 - if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
376 - require_lib( 'memberships' );
377 - $product = Memberships_Product::get_from_post( get_current_blog_id(), $product_id );
378 - if ( is_wp_error( $product ) ) {
379 - return array( 'error' => $product->get_error_message() );
380 - }
381 - if ( ! $product || ! $product instanceof Memberships_Product ) {
382 - return array( 'error' => __( 'Product not found.', 'jetpack' ) );
383 - }
561 + if ( isset( $request['description'] ) ) {
562 + $payload['description'] = $request['description'];
563 + }
384 564
385 - $updated_product = $product->update( $payload );
386 - if ( is_wp_error( $updated_product ) ) {
387 - return array( 'error' => $updated_product->get_error_message() );
388 - }
389 - return array( 'product' => $updated_product->to_array() );
390 - } else {
391 - $blog_id = Jetpack_Options::get_option( 'id' );
392 - $response = Client::wpcom_json_api_request_as_user(
393 - "/sites/$blog_id/{$this->rest_base}/product/$product_id",
394 - 'v2',
395 - array(
396 - 'method' => 'POST',
397 - ),
398 - $payload
399 - );
400 - if ( is_wp_error( $response ) ) {
401 - if ( $response->get_error_code() === 'missing_token' ) {
402 - return new WP_Error( 'missing_token', __( 'Please connect your user account to WordPress.com', 'jetpack' ), 404 );
403 - }
404 - return new WP_Error( 'wpcom_connection_error', __( 'Could not connect to WordPress.com', 'jetpack' ), 404 );
405 - }
406 - $data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null;
407 - // If endpoint returned error, we have to detect it.
408 - if ( 200 !== $response['response']['code'] && $data['code'] && $data['message'] ) {
409 - return new WP_Error( $data['code'], $data['message'], 401 );
410 - }
411 - return $data;
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;
412 581 }
413 582
414 - return $request;
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;
415 594 }
416 595
417 596 /**
418 - * Delete an existing memberships product
597 + * Validate yearly tier requirements.
419 598 *
420 - * @param \WP_REST_Request $request The request passed from WP.
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.
421 617 *
422 - * @return array|WP_Error
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.
423 621 */
424 - public function delete_product( \WP_REST_Request $request ) {
425 - $product_id = $request->get_param( 'product_id' );
426 - if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
427 - require_lib( 'memberships' );
428 - $product = Memberships_Product::get_from_post( get_current_blog_id(), $product_id );
429 - if ( is_wp_error( $product ) ) {
430 - return array( 'error' => $product->get_error_message() );
431 - }
432 - if ( ! $product || ! $product instanceof Memberships_Product ) {
433 - return array( 'error' => __( 'Product not found.', 'jetpack' ) );
434 - }
622 + private function validate_tier_references( WP_REST_Request $request, $tier ) {
623 + require_lib( 'memberships' );
624 + Memberships_Store_Sandbox::get_instance()->init( true );
435 625
436 - $result = $product->delete();
437 - if ( is_wp_error( $result ) ) {
438 - return array( 'error' => $result->get_error_message() );
439 - }
440 - return array( 'deleted' => true );
441 - } else {
442 - $blog_id = Jetpack_Options::get_option( 'id' );
443 - $response = Client::wpcom_json_api_request_as_user(
444 - "/sites/$blog_id/{$this->rest_base}/product/$product_id",
445 - 'v2',
446 - array(
447 - 'method' => 'DELETE',
448 - )
449 - );
450 - if ( is_wp_error( $response ) ) {
451 - if ( $response->get_error_code() === 'missing_token' ) {
452 - return new WP_Error( 'missing_token', __( 'Please connect your user account to WordPress.com', 'jetpack' ), 404 );
453 - }
454 - return new WP_Error( 'wpcom_connection_error', __( 'Could not connect to WordPress.com', 'jetpack' ), 404 );
455 - }
456 - $data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null;
457 - // If endpoint returned error, we have to detect it.
458 - if ( 200 !== $response['response']['code'] && $data['code'] && $data['message'] ) {
459 - return new WP_Error( $data['code'], $data['message'], 401 );
460 - }
461 - return $data;
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 ) );
462 630 }
463 631
464 - return $request;
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 );
465 638 }
466 639
467 640 /**
468 - * Get a status of connection for the site. If this is Jetpack, pass the request to wpcom.
641 + * Check for duplicate tier references.
469 642 *
470 - * @param \WP_REST_Request $request - request passed from WP.
471 - *
472 - * @return WP_Error|array ['products','connected_account_id','connect_url']
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.
473 646 */
474 - public function get_status( \WP_REST_Request $request ) {
475 - $product_type = $request['type'];
476 - $source = $request['source'];
477 - $is_editable = ! isset( $request['is_editable'] ) ? null : (bool) $request['is_editable'];
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 + }
478 652
479 - if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ) {
480 - require_lib( 'memberships' );
481 - $blog_id = get_current_blog_id();
482 - return (array) get_memberships_settings_for_site( $blog_id, $product_type, $is_editable, $source );
483 - } else {
484 - $payload = array(
485 - 'type' => $request['type'],
486 - 'source' => $source,
487 - );
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 + }
488 657
489 - // If we pass directly is_editable as null, it would break API argument validation.
490 - // This also needs to be converted to int because boolean false is ignored by add_query_arg.
491 - if ( null !== $is_editable ) {
492 - $payload['is_editable'] = (int) $is_editable;
493 - }
494 -
495 - $blog_id = Jetpack_Options::get_option( 'id' );
496 - $path = "/sites/$blog_id/{$this->rest_base}/status";
497 - if ( $product_type ) {
498 - $path = add_query_arg(
499 - $payload,
500 - $path
501 - );
502 - }
503 - $response = Client::wpcom_json_api_request_as_user( $path, 'v2' );
504 - if ( is_wp_error( $response ) ) {
505 - if ( $response->get_error_code() === 'missing_token' ) {
506 - return new WP_Error( 'missing_token', __( 'Please connect your user account to WordPress.com', 'jetpack' ), 404 );
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 ) );
507 664 }
508 - return new WP_Error( 'wpcom_connection_error', __( 'Could not connect to WordPress.com', 'jetpack' ), 404 );
509 665 }
510 - $data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null;
511 - if ( 200 !== $response['response']['code'] && $data['code'] && $data['message'] ) {
512 - return new WP_Error( $data['code'], $data['message'], 401 );
513 - }
514 - return $data;
515 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;
516 678 }
517 679 }
518 680
519 681 if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Jetpack::is_connection_ready() ) {