PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
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 14.1.1 14.2.2 All 502 releases
jetpack / _inc / lib / core-api / wpcom-endpoints / memberships.php

memberships.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at _inc/lib/core-api/wpcom-endpoints/memberships.php

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