PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 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 All 506 releases
← All changes | modules/memberships/class-jetpack-memberships.php +529 -74 12.7.3 → 16.3-a.7 View file →
@@ -6,10 +6,19 @@
6 6 * @since 7.3.0
7 7 */
8 8
9 9 use Automattic\Jetpack\Blocks;
10 -use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Token_Subscription_Service;
10 +use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service;
11 +use Automattic\Jetpack\Status;
12 +use Automattic\Jetpack\Status\Host;
13 +use Automattic\Jetpack\Status\Request;
14 +use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_LEVEL_ACCESS_SETTINGS;
15 +use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_TIER_ID_SETTINGS;
11 16
17 +if ( ! defined( 'ABSPATH' ) ) {
18 + exit( 0 );
19 +}
20 +
12 21 require_once __DIR__ . '/../../extensions/blocks/subscriptions/constants.php';
13 22
14 23 /**
15 24 * Class Jetpack_Memberships
@@ -29,20 +38,23 @@
29 38 */
30 39 public static $post_type_plan = 'jp_mem_plan';
31 40
32 41 /**
33 - * Option that will store currently set up account (Stripe etc) id for memberships.
42 + * Our CPT type for the product (plan).
34 43 *
35 - * TODO: remove
44 + * @var string
45 + */
46 + public static $post_type_coupon = 'memberships_coupon';
47 +
48 + /**
49 + * Tier type for plans
36 50 *
37 - * @deprecated
38 51 * @var string
39 52 */
40 - public static $connected_account_id_option_name = 'jetpack-memberships-connected-account-id';
53 + public static $type_tier = 'tier';
41 54
42 55 /**
43 - * Option that will toggle account enabled for memberships (i.e. Stripe is
44 - * configured, etc. ).
56 + * Option stores status for memberships (Stripe, etc.).
45 57 *
46 58 * @var string
47 59 */
48 60 public static $has_connected_account_option_name = 'jetpack-memberships-has-connected-account';
@@ -51,11 +63,18 @@
51 63 * Post meta that will store the level of access for newsletters
52 64 *
53 65 * @var string
54 66 */
55 - public static $post_access_level_meta_name = \Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_LEVEL_ACCESS_SETTINGS;
67 + public static $post_access_level_meta_name = META_NAME_FOR_POST_LEVEL_ACCESS_SETTINGS;
56 68
57 69 /**
70 + * Post meta that will store the tier ID of access for newsletters
71 + *
72 + * @var string
73 + */
74 + public static $post_access_tier_meta_name = META_NAME_FOR_POST_TIER_ID_SETTINGS;
75 +
76 + /**
58 77 * Button block type to use.
59 78 *
60 79 * @var string
61 80 */
@@ -68,8 +87,33 @@
68 87 */
69 88 private static $tags_allowed_in_the_button = array( 'br' => array() );
70 89
71 90 /**
91 + * Allowed HTML tags for a rendered tier description. Mirrors the wp.com
92 + * subscribe modal's allowlist so the rendered markdown stays consistent
93 + * across surfaces.
94 + *
95 + * @var array
96 + */
97 + const TIER_DESCRIPTION_ALLOWED_HTML = array(
98 + 'p' => array(),
99 + 'br' => array(),
100 + 'ul' => array(),
101 + 'ol' => array(),
102 + 'li' => array(),
103 + 'strong' => array(),
104 + 'em' => array(),
105 + 'del' => array(),
106 + 'code' => array(),
107 + 'blockquote' => array(),
108 + 'a' => array(
109 + 'href' => true,
110 + 'rel' => true,
111 + 'target' => true,
112 + ),
113 + );
114 +
115 + /**
72 116 * The minimum required plan for this Gutenberg block.
73 117 *
74 118 * @var string Plan slug
75 119 */
@@ -103,8 +147,22 @@
103 147 */
104 148 private static $user_is_paid_subscriber_cache = array();
105 149
106 150 /**
151 + * Cached results of get_post_access_level method.
152 + *
153 + * @var array
154 + */
155 + private static $post_access_level_cache = array();
156 +
157 + /**
158 + * Clear cached results of get_post_access_level method.
159 + */
160 + public static function clear_post_access_level_cache() {
161 + self::$post_access_level_cache = array();
162 + }
163 +
164 + /**
107 165 * Currencies we support and Stripe's minimum amount for a transaction in that currency.
108 166 *
109 167 * @link https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
110 168 *
@@ -127,8 +185,17 @@
127 185 'NZD' => 0.5,
128 186 'PLN' => 2.0,
129 187 'SEK' => 3.0,
130 188 'SGD' => 0.5,
189 + 'CZK' => 15.0,
190 + 'HUF' => 175.0,
191 + 'TWD' => 10.0,
192 + 'IDR' => 0,
193 + 'ILS' => 0,
194 + 'PHP' => 0,
195 + 'RUB' => 0,
196 + 'TRY' => 0,
197 + 'MYR' => 2.00,
131 198 );
132 199
133 200 /**
134 201 * Jetpack_Memberships constructor.
@@ -145,9 +212,9 @@
145 212 self::$instance = new self();
146 213 self::$instance->register_init_hook();
147 214 // Yes, `pro-plan` with a dash, `jetpack_personal` with an underscore. Check the v1.5 endpoint to verify.
148 215 $wpcom_plan_slug = defined( 'ENABLE_PRO_PLAN' ) ? 'pro-plan' : 'personal-bundle';
149 - self::$required_plan = ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ? $wpcom_plan_slug : 'jetpack_personal';
216 + self::$required_plan = ( new Host() )->is_wpcom_simple() ? $wpcom_plan_slug : 'jetpack_personal';
150 217 }
151 218
152 219 return self::$instance;
153 220 }
@@ -177,8 +244,11 @@
177 244 ),
178 245 'is_deleted' => array(
179 246 'meta' => $meta_prefix . 'is_deleted',
180 247 ),
248 + 'is_sandboxed' => array(
249 + 'meta' => $meta_prefix . 'is_sandboxed',
250 + ),
181 251 );
182 252 return $properties;
183 253 }
184 254
@@ -187,8 +257,10 @@
187 257 */
188 258 private function register_init_hook() {
189 259 add_action( 'init', array( $this, 'init_hook_action' ) );
190 260 add_action( 'jetpack_register_gutenberg_extensions', array( $this, 'register_gutenberg_block' ) );
261 + // phpcs:ignore WPCUT.SwitchBlog.SwitchBlog -- wpcom flags **every** use of switch_blog, apparently expecting valid instances to ignore or suppress the sniff.
262 + add_action( 'switch_blog', array( $this, 'clear_post_access_level_cache' ) );
191 263 }
192 264
193 265 /**
194 266 * Actual hooks initializing on init.
@@ -196,11 +268,26 @@
196 268 public function init_hook_action() {
197 269 add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
198 270 add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
199 271 $this->setup_cpts();
272 +
273 + if ( Jetpack::is_module_active( 'subscriptions' ) && Request::is_frontend() ) {
274 + add_action( 'wp_logout', array( $this, 'subscriber_logout' ) );
275 + }
200 276 }
201 277
202 278 /**
279 + * Logs the subscriber out by clearing out the premium content cookie.
280 + */
281 + public function subscriber_logout() {
282 + if ( ! class_exists( 'Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service' ) ) {
283 + return;
284 + }
285 +
286 + Abstract_Token_Subscription_Service::clear_token_cookie();
287 + }
288 +
289 + /**
203 290 * Sets up the custom post types for the module.
204 291 */
205 292 private function setup_cpts() {
206 293 /*
@@ -233,8 +320,27 @@
233 320 'capabilities' => $capabilities,
234 321 'show_in_rest' => false,
235 322 );
236 323 register_post_type( self::$post_type_plan, $order_args );
324 + $coupon_args = array(
325 + 'label' => esc_html__( 'Coupon', 'jetpack' ),
326 + 'description' => esc_html__( 'Memberships coupons', 'jetpack' ),
327 + 'supports' => array( 'title', 'custom-fields', 'content' ),
328 + 'hierarchical' => false,
329 + 'public' => false,
330 + 'show_ui' => false,
331 + 'show_in_menu' => false,
332 + 'show_in_admin_bar' => false,
333 + 'show_in_nav_menus' => false,
334 + 'can_export' => true,
335 + 'has_archive' => false,
336 + 'exclude_from_search' => true,
337 + 'publicly_queryable' => false,
338 + 'rewrite' => false,
339 + 'capabilities' => $capabilities,
340 + 'show_in_rest' => false,
341 + );
342 + register_post_type( self::$post_type_coupon, $coupon_args );
237 343 }
238 344
239 345 /**
240 346 * Allows custom post types to be used by REST API.
@@ -245,8 +351,9 @@
245 351 * @return array
246 352 */
247 353 public function allow_rest_api_types( $post_types ) {
248 354 $post_types[] = self::$post_type_plan;
355 + $post_types[] = self::$post_type_coupon;
249 356
250 357 return $post_types;
251 358 }
252 359
@@ -257,13 +364,37 @@
257 364 *
258 365 * @return array
259 366 */
260 367 public function allow_sync_post_meta( $post_meta ) {
261 - $meta_keys = array_map(
368 + $meta_keys_plans = array_map(
262 369 array( $this, 'return_meta' ),
263 370 self::get_plan_property_mapping()
264 371 );
265 - return array_merge( $post_meta, array_values( $meta_keys ) );
372 +
373 + $meta_coupons_prefix = self::$post_type_coupon . '_';
374 + $meta_keys_coupons = array(
375 + $meta_coupons_prefix . 'coupon_code',
376 + $meta_coupons_prefix . 'can_be_combined',
377 + $meta_coupons_prefix . 'first_time_purchase_only',
378 + $meta_coupons_prefix . 'limit_per_user',
379 + $meta_coupons_prefix . 'discount_type',
380 + $meta_coupons_prefix . 'discount_value',
381 + $meta_coupons_prefix . 'discount_percentage',
382 + $meta_coupons_prefix . 'discount_currency',
383 + $meta_coupons_prefix . 'start_date',
384 + $meta_coupons_prefix . 'end_date',
385 + $meta_coupons_prefix . 'plan_ids_allow_list',
386 + $meta_coupons_prefix . 'duration',
387 + $meta_coupons_prefix . 'email_allow_list',
388 + $meta_coupons_prefix . 'is_deleted',
389 + $meta_coupons_prefix . 'is_sandboxed',
390 + );
391 +
392 + return array_merge(
393 + $post_meta,
394 + array_values( $meta_keys_plans ),
395 + $meta_keys_coupons
396 + );
266 397 }
267 398
268 399 /**
269 400 * This returns meta attribute of passet array.
@@ -277,8 +408,21 @@
277 408 return $map['meta'];
278 409 }
279 410
280 411 /**
412 + * Show an error to the user (or embed a clue in the HTML) when the button does not get rendered properly.
413 + *
414 + * @param WP_Error $error The error message with error code.
415 + * @return string The error message rendered as HTML.
416 + */
417 + public function render_button_error( $error ) {
418 + if ( static::user_can_edit() ) {
419 + return '<div><strong>Jetpack Memberships Error: ' . $error->get_error_code() . '</strong><br />' . $error->get_error_message() . '</div>';
420 + }
421 + return '<div>Sorry! This product is not available for purchase at this time.</div><!-- Jetpack Memberships Error: ' . $error->get_error_code() . ' -->';
422 + }
423 +
424 + /**
281 425 * Renders a preview of the Recurring Payment button, which is not hooked
282 426 * up to the subscription url. Used to preview the block on the frontend
283 427 * for site editors when Stripe has not been connected.
284 428 *
@@ -318,11 +462,11 @@
318 462 $is_premium_content_child = (int) $block->context['isPremiumContentChild'];
319 463 }
320 464
321 465 return $is_premium_content_child &&
322 - $user_can_edit &&
323 - $requires_stripe_connection &&
324 - $jetpack_ready;
466 + $user_can_edit &&
467 + $requires_stripe_connection &&
468 + $jetpack_ready;
325 469 }
326 470
327 471 /**
328 472 * Callback that parses the membership purchase shortcode.
@@ -330,38 +474,68 @@
330 474 * @param array $attributes - attributes in the shortcode. `id` here is the CPT id of the plan.
331 475 * @param string $content - Recurring Payment block content.
332 476 * @param WP_Block $block - Recurring Payment block instance.
333 477 *
334 - * @return string|void
478 + * @return string|void - HTML for the button, void removes the button.
335 479 */
336 480 public function render_button( $attributes, $content = null, $block = null ) {
337 - Jetpack_Gutenberg::load_assets_as_required( self::$button_block_name, array( 'thickbox', 'wp-polyfill' ) );
481 + Jetpack_Gutenberg::load_assets_as_required( self::$button_block_name );
338 482
339 483 if ( $this->should_render_button_preview( $block ) ) {
340 484 return $this->render_button_preview( $attributes, $content );
341 485 }
342 486
343 - if ( empty( $attributes['planId'] ) ) {
344 - return;
487 + if ( empty( $attributes['planId'] ) && empty( $attributes['planIds'] ) ) {
488 + return $this->render_button_error( new WP_Error( 'jetpack-memberships-rb-npi', __( 'No plan was configured for this button.', 'jetpack' ) . ' ' . __( 'Edit this post and confirm that an existing payment plan is selected for this block.', 'jetpack' ) ) );
345 489 }
346 490
347 - $plan_id = (int) $attributes['planId'];
348 - $product = get_post( $plan_id );
349 - if ( ! $product || is_wp_error( $product ) ) {
350 - return;
491 + // This is string of '+` separated plan ids. Loop through them and
492 + // filter out the ones that are not valid.
493 + $plan_ids = array();
494 + if ( ! empty( $attributes['planIds'] ) ) {
495 + $plan_ids = $attributes['planIds'];
496 + } elseif ( ! empty( $attributes['planId'] ) ) {
497 + $plan_ids = explode( '+', $attributes['planId'] );
351 498 }
352 - if ( $product->post_type !== self::$post_type_plan || 'publish' !== $product->post_status ) {
499 + $valid_plans = array();
500 + foreach ( $plan_ids as $plan_id ) {
501 + if ( ! is_numeric( $plan_id ) ) {
502 + continue;
503 + }
504 + $product = get_post( $plan_id );
505 + if ( ! $product ) {
506 + return $this->render_button_error( new WP_Error( 'jetpack-memberships-rb-npf', __( 'Could not find a plan for this button.', 'jetpack' ) . ' ' . __( 'Edit this post and confirm that the selected payment plan still exists and is available for purchase.', 'jetpack' ) ) );
507 + }
508 + if ( is_wp_error( $product ) ) {
509 + '@phan-var WP_Error $product'; // `get_post` isn't supposed to return a WP_Error, so Phan is confused here. See also https://github.com/phan/phan/issues/3127
510 + return $this->render_button_error( new WP_Error( 'jetpack-memberships-rb-npf-we', __( 'Encountered an error when getting the plan associated with this button:', 'jetpack' ) . ' ' . $product->get_error_message() . '. ' . __( ' Edit this post and confirm that the selected payment plan still exists and is available for purchase.', 'jetpack' ) ) );
511 + }
512 + if ( $product->post_type !== self::$post_type_plan ) {
513 + return $this->render_button_error( new WP_Error( 'jetpack-memberships-rb-pnplan', __( 'The payment plan selected is not actually a payment plan.', 'jetpack' ) . ' ' . __( 'Edit this post and confirm that the selected payment plan still exists and is available for purchase.', 'jetpack' ) ) );
514 + }
515 + if ( 'publish' !== $product->post_status ) {
516 + return $this->render_button_error( new WP_Error( 'jetpack-memberships-rb-psnpub', __( 'The selected payment plan is not active.', 'jetpack' ) . ' ' . __( 'Edit this post and confirm that the selected payment plan still exists and is available for purchase.', 'jetpack' ) ) );
517 + }
518 + $valid_plans[] = $plan_id;
519 + }
520 +
521 + // If none are valid, return.
522 + // (Returning like this makes the button disappear.)
523 + if ( empty( $valid_plans ) ) {
353 524 return;
354 525 }
526 + $plan_id = implode( '+', $valid_plans );
355 527
356 - add_thickbox();
357 -
358 528 if ( ! empty( $content ) ) {
359 529 $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
360 530 $content = str_replace( 'recurring-payments-id', $block_id, $content );
361 531 $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
362 532 $subscribe_url = $this->get_subscription_url( $plan_id );
363 - return preg_replace( '/(href=".*")/U', 'href="' . $subscribe_url . '"', $content );
533 +
534 + $content = preg_replace( '/(href=".*")/U', 'href="' . $subscribe_url . '"', $content );
535 + $content = wp_kses_post( $content );
536 +
537 + return $content;
364 538 }
365 539
366 540 return $this->deprecated_render_button_v1( $attributes, $plan_id );
367 541 }
@@ -366,8 +540,45 @@
366 540 return $this->deprecated_render_button_v1( $attributes, $plan_id );
367 541 }
368 542
369 543 /**
544 + * Render email callback.
545 + *
546 + * @param string $block_content The block content.
547 + * @param array $parsed_block The parsed block data.
548 + * @param object $rendering_context The email rendering context.
549 + *
550 + * @return string
551 + */
552 + public function render_button_email( $block_content, array $parsed_block, $rendering_context ) {
553 + // Check for the required renderers.
554 + if ( ! function_exists( '\Automattic\Jetpack\Extensions\Button\render_email' ) || ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Button' ) ) {
555 + return '';
556 + }
557 +
558 + // Get the first inner block, which should be the button block.
559 + $button_block = $parsed_block['innerBlocks'][0] ?? array();
560 +
561 + // We should only accept button blocks.
562 + if ( empty( $button_block['blockName'] ) || 'jetpack/button' !== $button_block['blockName'] ) {
563 + return '';
564 + }
565 +
566 + // We need attributes.
567 + if ( ! isset( $button_block['attrs'] ) || ! is_array( $button_block['attrs'] ) ) {
568 + return '';
569 + }
570 +
571 + // If the button block is missing text or url, return empty string.
572 + if ( empty( $button_block['attrs']['text'] ) || empty( $button_block['attrs']['url'] ) ) {
573 + return '';
574 + }
575 +
576 + // Reuse the button block's email rendering method.
577 + return \Automattic\Jetpack\Extensions\Button\render_email( $block_content, $button_block, $rendering_context );
578 + }
579 +
580 + /**
370 581 * Builds subscription URL for this membership using the current blog and
371 582 * supplied plan IDs.
372 583 *
373 584 * @param integer $plan_id - Unique ID for the plan being subscribed to.
@@ -396,11 +607,9 @@
396 607 *
397 608 * @return string
398 609 */
399 610 public function deprecated_render_button_v1( $attrs, $plan_id ) {
400 - $button_label = isset( $attrs['submitButtonText'] )
401 - ? $attrs['submitButtonText']
402 - : __( 'Your contribution', 'jetpack' );
611 + $button_label = $attrs['submitButtonText'] ?? __( 'Your contribution', 'jetpack' );
403 612
404 613 $button_styles = array();
405 614 if ( ! empty( $attrs['customBackgroundButtonColor'] ) ) {
406 615 array_push(
@@ -463,11 +672,9 @@
463 672 if ( $has_option ) {
464 673 return true;
465 674 }
466 675
467 - // This is the fallback solution.
468 - // TODO: Remove this once the has_connected_account_option is migrated to all sites.
469 - return get_option( 'jetpack-memberships-connected-account-id', false ) ? true : false;
676 + return false;
470 677 }
471 678
472 679 /**
473 680 * Get the post access level
@@ -482,19 +689,70 @@
482 689 if ( ! $post_id ) {
483 690 $post_id = get_the_ID();
484 691 }
485 692 if ( ! $post_id ) {
486 - return Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
693 + return Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
487 694 }
488 695
696 + $blog_id = get_current_blog_id();
697 + $cache_key = $blog_id . '_' . $post_id;
698 +
699 + if ( isset( self::$post_access_level_cache[ $cache_key ] ) ) {
700 + return self::$post_access_level_cache[ $cache_key ];
701 + }
702 +
489 703 $post_access_level = get_post_meta( $post_id, self::$post_access_level_meta_name, true );
490 - if ( empty( $post_access_level ) ) {
491 - $post_access_level = Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
704 + // Defaults to "everybody" when unset, and also when the stored value is not a
705 + // string. Corrupt rows (e.g. a serialized array like a:1:{i:0;s:0:"";}) can be
706 + // persisted by non-REST write paths, and an array flows unchanged into the
707 + // strict string-typed `earn_user_has_access` callback on WPCOM, fataling the
708 + // render. Coercing here keeps this canonical accessor's documented string
709 + // contract regardless of how the meta was written.
710 + if ( empty( $post_access_level ) || ! is_string( $post_access_level ) ) {
711 + $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
492 712 }
713 +
714 + // Only the editor switches a Paywall post to subscribers; REST, WP-CLI and importer saves don't.
715 + // The block's name constant isn't loaded everywhere this runs, hence the literal.
716 + if (
717 + Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level
718 + && has_block( 'jetpack/paywall', $post_id )
719 + ) {
720 + $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS;
721 + }
722 +
723 + self::$post_access_level_cache[ $cache_key ] = $post_access_level;
724 +
493 725 return $post_access_level;
494 726 }
495 727
496 728 /**
729 + * Get the post tier plan
730 + *
731 + * If no ID is provided, the method tries to get it from the global post object.
732 + *
733 + * @param int|null $post_id The ID of the post. Default is null.
734 + *
735 + * @return WP_Post|null the actual post tier.
736 + */
737 + public static function get_post_tier( $post_id = null ) {
738 + if ( ! $post_id ) {
739 + $post_id = get_the_ID();
740 + }
741 +
742 + if ( ! $post_id ) {
743 + return null;
744 + }
745 +
746 + $post_tier_id = get_post_meta( $post_id, self::$post_access_tier_meta_name, true );
747 + if ( empty( $post_tier_id ) ) {
748 + return null;
749 + }
750 +
751 + return get_post( $post_tier_id );
752 + }
753 +
754 + /**
497 755 * Determines whether the current user can edit.
498 756 *
499 757 * @return bool Whether the user can edit.
500 758 */
@@ -499,27 +757,65 @@
499 757 * @return bool Whether the user can edit.
500 758 */
501 759 public static function user_can_edit() {
502 760 $user = wp_get_current_user();
503 - // phpcs:ignore ImportDetection.Imports.RequireImports.Symbol
504 761 return 0 !== $user->ID && current_user_can( 'edit_post', get_the_ID() );
505 762 }
506 763
507 764 /**
508 - * Determines whether the current user can view the post based on the newsletter access level
509 - * and caches the result.
765 + * Clears the static cache for all users or for a given user.
510 766 *
767 + * @param int|null $user_id The user_id to unset in the cache, otherwise the entire static cache is cleared.
768 + * @return void
769 + */
770 + public static function clear_cache( ?int $user_id = null ) {
771 + if ( empty( $user_id ) ) {
772 + self::$user_is_paid_subscriber_cache = array();
773 + self::$user_can_view_post_cache = array();
774 + return;
775 + }
776 + unset( self::$user_is_paid_subscriber_cache[ $user_id ] );
777 + unset( self::$user_can_view_post_cache[ $user_id ] );
778 + }
779 +
780 + /**
781 + * Determines whether the current user is a paid subscriber and caches the result.
782 + *
783 + * @param array $valid_plan_ids An array of valid plan ids that the user could be subscribed to which would make the user able to view this content. Defaults to an empty array which will be filled with all newsletter plan IDs.
784 + * @param int|null $user_id An optional user_id that can be used to determine service availability (defaults to checking if user is logged in if omitted).
511 785 * @return bool Whether the post can be viewed
512 786 */
513 - public static function user_is_paid_subscriber() {
514 - $user_id = get_current_user_id();
787 + public static function user_is_paid_subscriber( $valid_plan_ids = array(), $user_id = null ) {
788 + if ( empty( $user_id ) ) {
789 + $user_id = get_current_user_id();
790 + if ( empty( $user_id ) ) {
791 + return false;
792 + }
793 + }
794 + // sort and stringify sorted valid plan ids to use as a cache key
795 + sort( $valid_plan_ids );
796 + $cache_key = $user_id . '_' . implode( ',', $valid_plan_ids );
797 + if ( ! isset( self::$user_is_paid_subscriber_cache[ $cache_key ] ) ) {
798 + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
799 + if ( empty( $valid_plan_ids ) ) {
800 + $valid_plan_ids = self::get_all_newsletter_plan_ids();
801 + }
802 + $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service( $user_id );
803 + $is_paid_subscriber = $paywall->visitor_can_view_content( $valid_plan_ids, Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS );
804 + self::$user_is_paid_subscriber_cache[ $cache_key ] = $is_paid_subscriber;
805 + }
806 + return self::$user_is_paid_subscriber_cache[ $cache_key ];
807 + }
515 808
809 + /**
810 + * Determines whether the current user has a pending subscription.
811 + *
812 + * @return bool Whether the user has a pending subscription
813 + */
814 + public static function user_is_pending_subscriber() {
516 815 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
517 - $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
518 - $is_paid_subscriber = $paywall->visitor_can_view_content( self::get_all_newsletter_plan_ids(), Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS );
519 -
520 - self::$user_is_paid_subscriber_cache[ $user_id ] = $is_paid_subscriber;
521 - return $is_paid_subscriber;
816 + $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
817 + return $subscription_service->is_current_user_pending_subscriber();
522 818 }
523 819
524 820 /**
525 821 * Determines whether the current user can view the post based on the newsletter access level
@@ -524,13 +820,17 @@
524 820 /**
525 821 * Determines whether the current user can view the post based on the newsletter access level
526 822 * and caches the result.
527 823 *
824 + * @param int|null $post_id Explicit post id to check against.
825 + *
528 826 * @return bool Whether the post can be viewed
529 827 */
530 - public static function user_can_view_post() {
828 + public static function user_can_view_post( $post_id = null ) {
531 829 $user_id = get_current_user_id();
532 - $post_id = get_the_ID();
830 + if ( null === $post_id ) {
831 + $post_id = get_the_ID();
832 + }
533 833
534 834 if ( false === $post_id ) {
535 835 $post_id = 0;
536 836 }
@@ -535,30 +835,42 @@
535 835 $post_id = 0;
536 836 }
537 837
538 838 $cache_key = sprintf( '%d_%d', $user_id, $post_id );
539 - if ( $user_id !== 0 && isset( self::$user_can_view_post_cache[ $cache_key ] ) ) {
839 + if ( isset( self::$user_can_view_post_cache[ $cache_key ] ) ) {
540 840 return self::$user_can_view_post_cache[ $cache_key ];
541 841 }
542 842
543 - $post_access_level = self::get_post_access_level();
544 - if ( Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
843 + $post_access_level = self::get_post_access_level( $post_id );
844 + if ( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
545 845 self::$user_can_view_post_cache[ $cache_key ] = true;
546 846 return true;
547 847 }
548 848
549 - if ( $user_id === 0 ) {
550 - if ( defined( 'WPCOM_SENDING_POST_TO_SUBSCRIBERS' ) && WPCOM_SENDING_POST_TO_SUBSCRIBERS ) {
551 - if ( Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS === $post_access_level ) {
552 - return true;
553 - }
554 - }
849 + // we are sending the post to subscribers so the user is a subscriber
850 + if ( defined( 'WPCOM_SENDING_POST_TO_SUBSCRIBERS' ) && WPCOM_SENDING_POST_TO_SUBSCRIBERS && Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS === $post_access_level ) {
851 + self::$user_can_view_post_cache[ $cache_key ] = true;
852 + return true;
555 853 }
556 854
557 855 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
558 - $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
559 - $can_view_post = $paywall->visitor_can_view_content( self::get_all_newsletter_plan_ids(), $post_access_level );
856 + $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
560 857
858 + $all_newsletters_plan_ids = self::get_all_newsletter_plan_ids();
859 +
860 + if ( 0 === count( $all_newsletters_plan_ids ) &&
861 + (
862 + Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS === $post_access_level ||
863 + Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS === $post_access_level
864 + )
865 + ) {
866 + // The post is paywalled but there is no newsletter plans on the site.
867 + // We downgrade the post level to subscribers-only
868 + $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS;
869 + }
870 +
871 + $can_view_post = $paywall->visitor_can_view_content( $all_newsletters_plan_ids, $post_access_level );
872 +
561 873 self::$user_can_view_post_cache[ $cache_key ] = $can_view_post;
562 874 return $can_view_post;
563 875 }
564 876
@@ -569,13 +881,31 @@
569 881 *
570 882 * @return bool
571 883 */
572 884 public static function is_enabled_jetpack_recurring_payments() {
573 - $api_available = ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Jetpack::is_connection_ready() );
885 + $api_available = ( new Host() )->is_wpcom_simple() || Jetpack::is_connection_ready();
574 886 return $api_available;
575 887 }
576 888
577 889 /**
890 + * Whether to enable the blocks in the editor.
891 + * All Monetize blocks (except Simple Payments) need a user with at least `edit_posts` capability
892 + *
893 + * @return bool
894 + */
895 + public static function should_enable_monetize_blocks_in_editor() {
896 + if ( ! is_admin() ) {
897 + // We enable the block for the front-end in all cases
898 + return true;
899 +
900 + }
901 +
902 + $is_offline_mode = ( new Status() )->is_offline_mode();
903 + $enable_monetize_blocks_in_editor = ( new Host() )->is_wpcom_simple() || ( ! $is_offline_mode );
904 + return $enable_monetize_blocks_in_editor;
905 + }
906 +
907 + /**
578 908 * Whether site has any paid plan.
579 909 *
580 910 * @param string $type - Type of a plan for which site is configured. For now supports empty and newsletter.
581 911 *
@@ -600,26 +930,90 @@
600 930 return ( is_countable( $plans ) && count( $plans ) > 0 );
601 931 }
602 932
603 933 /**
604 - * Return all membership plans (deleted or not)
934 + * Return the list of plan posts
605 935 *
936 + * @return WP_Post[]|WP_Error
937 + */
938 + public static function get_all_plans() {
939 + if ( ! self::is_enabled_jetpack_recurring_payments() ) {
940 + return array();
941 + }
942 +
943 + // We can retrieve the data directly except on a Jetpack/Atomic cached site or
944 + $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
945 + if ( ! $is_cached_site ) {
946 + return get_posts(
947 + array(
948 + 'posts_per_page' => -1,
949 + 'post_type' => self::$post_type_plan,
950 + )
951 + );
952 + } else {
953 + // On cached site on WPCOM
954 + require_lib( 'memberships' );
955 + return Memberships_Product::get_plans_posts_list( get_current_blog_id() );
956 + }
957 + }
958 +
959 + /**
960 + * Return all membership plans ids (deleted or not)
961 + * This function is used both on WPCOM or on Jetpack self-hosted.
962 + * Depending on the environment we need to mitigate where the data is retrieved from.
963 + *
964 + * @param bool $allow_deleted Whether to allow deleted plans to be returned. Defaults to true.
965 + *
606 966 * @return array
607 967 */
608 - public static function get_all_newsletter_plan_ids() {
968 + public static function get_all_newsletter_plan_ids( $allow_deleted = true ) {
969 +
609 970 if ( ! self::is_enabled_jetpack_recurring_payments() ) {
610 971 return array();
611 972 }
612 973
613 - return get_posts(
614 - array(
615 - 'posts_per_page' => -1,
616 - 'fields' => 'ids',
617 - 'meta_value' => true,
618 - 'post_type' => self::$post_type_plan,
619 - 'meta_key' => 'jetpack_memberships_site_subscriber',
620 - )
621 - );
974 + // We can retrieve the data directly except on a Jetpack/Atomic cached site or
975 + $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
976 + if ( ! $is_cached_site ) {
977 + $meta_query = array(
978 + array(
979 + 'key' => 'jetpack_memberships_type',
980 + 'value' => self::$type_tier,
981 + ),
982 + );
983 +
984 + if ( $allow_deleted === false ) {
985 + $meta_query[] = array(
986 + 'key' => 'jetpack_memberships_is_deleted',
987 + 'compare' => 'NOT EXISTS',
988 + );
989 + }
990 +
991 + return get_posts(
992 + array(
993 + 'posts_per_page' => -1,
994 + 'fields' => 'ids',
995 + 'post_type' => self::$post_type_plan,
996 + 'meta_query' => $meta_query,
997 + )
998 + );
999 +
1000 + } else {
1001 + // On cached site on WPCOM
1002 + require_lib( 'memberships' );
1003 + $list = Memberships_Product::get_product_list( get_current_blog_id(), self::$type_tier, null, $allow_deleted );
1004 +
1005 + if ( is_wp_error( $list ) ) {
1006 + return array();
1007 + }
1008 +
1009 + return array_map(
1010 + function ( $product ) {
1011 + return $product['id'];
1012 + }, // Returning only post ids
1013 + $list
1014 + );
1015 + }
622 1016 }
623 1017
624 1018 /**
625 1019 * Register the Recurring Payments Gutenberg block
@@ -635,11 +1029,12 @@
635 1029 if ( self::is_enabled_jetpack_recurring_payments() ) {
636 1030 Blocks::jetpack_register_block(
637 1031 'jetpack/recurring-payments',
638 1032 array(
639 - 'render_callback' => array( $this, 'render_button' ),
640 - 'uses_context' => array( 'isPremiumContentChild' ),
641 - 'provides_context' => array(
1033 + 'render_callback' => array( $this, 'render_button' ),
1034 + 'render_email_callback' => array( $this, 'render_button_email' ),
1035 + 'uses_context' => array( 'isPremiumContentChild' ),
1036 + 'provides_context' => array(
642 1037 'jetpack/parentBlockWidth' => 'width',
643 1038 ),
644 1039 )
645 1040 );
@@ -644,9 +1039,9 @@
644 1039 )
645 1040 );
646 1041 } else {
647 1042 Jetpack_Gutenberg::set_extension_unavailable(
648 - 'jetpack/recurring-payments',
1043 + 'recurring-payments',
649 1044 'missing_plan',
650 1045 array(
651 1046 'required_feature' => 'memberships',
652 1047 'required_plan' => self::$required_plan,
@@ -675,7 +1070,67 @@
675 1070 }
676 1071
677 1072 /* translators: %s: number of folks following the blog */
678 1073 return sprintf( _n( 'Join %s other subscriber', 'Join %s other subscribers', $subscribers_total, 'jetpack' ), number_format_i18n( $subscribers_total ) );
1074 + }
1075 +
1076 + /**
1077 + * Returns the email of the current user.
1078 + *
1079 + * @return string
1080 + */
1081 + public static function get_current_user_email() {
1082 + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1083 + $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1084 + return $subscription_service->get_subscriber_email();
1085 + }
1086 +
1087 + /**
1088 + * Returns if the current user is subscribed or not.
1089 + *
1090 + * @return boolean
1091 + */
1092 + public static function is_current_user_subscribed() {
1093 + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1094 + $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1095 + return $subscription_service->is_current_user_subscribed();
1096 + }
1097 +
1098 + /**
1099 + * Render a tier description (stored as markdown text) to safe HTML.
1100 + *
1101 + * Uses Jetpack's markdown parser, restores paragraph structure (the parser
1102 + * strips <p> tags expecting wpautop to run later), forces links to open in a
1103 + * new tab (descriptions are shown inside the subscribe modal's iframe), and
1104 + * finally sanitizes the output to a small tag allowlist.
1105 + *
1106 + * @param mixed $description Raw tier description (markdown text). Non-scalar
1107 + * values are treated as empty.
1108 + * @return string Sanitized HTML, or an empty string for an empty description.
1109 + */
1110 + public static function render_tier_description_html( $description ) {
1111 + if ( ! is_scalar( $description ) ) {
1112 + return '';
1113 + }
1114 + $description = (string) $description;
1115 + if ( '' === trim( $description ) ) {
1116 + return '';
1117 + }
1118 +
1119 + if ( ! class_exists( 'WPCom_Markdown' ) ) {
1120 + require_once JETPACK__PLUGIN_DIR . 'modules/markdown/easy-markdown.php';
1121 + }
1122 +
1123 + $html = WPCom_Markdown::get_instance()->transform(
1124 + $description,
1125 + array(
1126 + 'unslash' => false,
1127 + 'id' => false,
1128 + )
1129 + );
1130 + $html = wpautop( $html );
1131 + $html = links_add_target( $html, '_blank' );
1132 +
1133 + return wp_kses( $html, self::TIER_DESCRIPTION_ALLOWED_HTML );
679 1134 }
680 1135 }
681 1136 Jetpack_Memberships::get_instance();