PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 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 All 507 releases
← All changes | modules/memberships/class-jetpack-memberships.php +632 -90 12.0.3 → 16.3-beta View file →
@@ -6,9 +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\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;
10 16
17 +if ( ! defined( 'ABSPATH' ) ) {
18 + exit( 0 );
19 +}
20 +
11 21 require_once __DIR__ . '/../../extensions/blocks/subscriptions/constants.php';
12 22
13 23 /**
14 24 * Class Jetpack_Memberships
@@ -26,23 +36,45 @@
26 36 *
27 37 * @var string
28 38 */
29 39 public static $post_type_plan = 'jp_mem_plan';
40 +
30 41 /**
31 - * Option that will store currently set up account (Stripe etc) id for memberships.
42 + * Our CPT type for the product (plan).
32 43 *
33 44 * @var string
34 45 */
35 - public static $connected_account_id_option_name = 'jetpack-memberships-connected-account-id';
46 + public static $post_type_coupon = 'memberships_coupon';
36 47
37 48 /**
49 + * Tier type for plans
50 + *
51 + * @var string
52 + */
53 + public static $type_tier = 'tier';
54 +
55 + /**
56 + * Option stores status for memberships (Stripe, etc.).
57 + *
58 + * @var string
59 + */
60 + public static $has_connected_account_option_name = 'jetpack-memberships-has-connected-account';
61 +
62 + /**
38 63 * Post meta that will store the level of access for newsletters
39 64 *
40 65 * @var string
41 66 */
42 - public static $newsletter_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;
43 68
44 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 + /**
45 77 * Button block type to use.
46 78 *
47 79 * @var string
48 80 */
@@ -55,8 +87,33 @@
55 87 */
56 88 private static $tags_allowed_in_the_button = array( 'br' => array() );
57 89
58 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 + /**
59 116 * The minimum required plan for this Gutenberg block.
60 117 *
61 118 * @var string Plan slug
62 119 */
@@ -83,14 +140,34 @@
83 140 */
84 141 private static $user_can_view_post_cache = array();
85 142
86 143 /**
144 + * Cached results of user_is_paid_subscriber() method.
145 + *
146 + * @var array
147 + */
148 + private static $user_is_paid_subscriber_cache = array();
149 +
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 + /**
87 165 * Currencies we support and Stripe's minimum amount for a transaction in that currency.
88 166 *
89 167 * @link https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
90 168 *
91 - * List has to be in with `SUPPORTED_CURRENCIES` in extensions/shared/currencies.js and
92 - * `Memberships_Product::SUPPORTED_CURRENCIES` in the WP.com memberships library.
169 + * List has to be in with `SUPPORTED_CURRENCIES` in extensions/shared/currencies.js.
93 170 */
94 171 const SUPPORTED_CURRENCIES = array(
95 172 'USD' => 0.5,
96 173 'AUD' => 0.5,
@@ -108,8 +185,17 @@
108 185 'NZD' => 0.5,
109 186 'PLN' => 2.0,
110 187 'SEK' => 3.0,
111 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,
112 198 );
113 199
114 200 /**
115 201 * Jetpack_Memberships constructor.
@@ -126,9 +212,9 @@
126 212 self::$instance = new self();
127 213 self::$instance->register_init_hook();
128 214 // Yes, `pro-plan` with a dash, `jetpack_personal` with an underscore. Check the v1.5 endpoint to verify.
129 215 $wpcom_plan_slug = defined( 'ENABLE_PRO_PLAN' ) ? 'pro-plan' : 'personal-bundle';
130 - 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';
131 217 }
132 218
133 219 return self::$instance;
134 220 }
@@ -140,14 +226,29 @@
140 226 */
141 227 private static function get_plan_property_mapping() {
142 228 $meta_prefix = 'jetpack_memberships_';
143 229 $properties = array(
144 - 'price' => array(
230 + 'price' => array(
145 231 'meta' => $meta_prefix . 'price',
146 232 ),
147 - 'currency' => array(
233 + 'currency' => array(
148 234 'meta' => $meta_prefix . 'currency',
149 235 ),
236 + 'site_subscriber' => array(
237 + 'meta' => $meta_prefix . 'site_subscriber',
238 + ),
239 + 'product_id' => array(
240 + 'meta' => $meta_prefix . 'product_id',
241 + ),
242 + 'tier' => array(
243 + 'meta' => $meta_prefix . 'tier',
244 + ),
245 + 'is_deleted' => array(
246 + 'meta' => $meta_prefix . 'is_deleted',
247 + ),
248 + 'is_sandboxed' => array(
249 + 'meta' => $meta_prefix . 'is_sandboxed',
250 + ),
150 251 );
151 252 return $properties;
152 253 }
153 254
@@ -156,8 +257,10 @@
156 257 */
157 258 private function register_init_hook() {
158 259 add_action( 'init', array( $this, 'init_hook_action' ) );
159 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' ) );
160 263 }
161 264
162 265 /**
163 266 * Actual hooks initializing on init.
@@ -165,11 +268,26 @@
165 268 public function init_hook_action() {
166 269 add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
167 270 add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
168 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 + }
169 276 }
170 277
171 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 + /**
172 290 * Sets up the custom post types for the module.
173 291 */
174 292 private function setup_cpts() {
175 293 /*
@@ -202,8 +320,27 @@
202 320 'capabilities' => $capabilities,
203 321 'show_in_rest' => false,
204 322 );
205 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 );
206 343 }
207 344
208 345 /**
209 346 * Allows custom post types to be used by REST API.
@@ -214,8 +351,9 @@
214 351 * @return array
215 352 */
216 353 public function allow_rest_api_types( $post_types ) {
217 354 $post_types[] = self::$post_type_plan;
355 + $post_types[] = self::$post_type_coupon;
218 356
219 357 return $post_types;
220 358 }
221 359
@@ -226,13 +364,37 @@
226 364 *
227 365 * @return array
228 366 */
229 367 public function allow_sync_post_meta( $post_meta ) {
230 - $meta_keys = array_map(
368 + $meta_keys_plans = array_map(
231 369 array( $this, 'return_meta' ),
232 - $this->get_plan_property_mapping()
370 + self::get_plan_property_mapping()
233 371 );
234 - 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 + );
235 397 }
236 398
237 399 /**
238 400 * This returns meta attribute of passet array.
@@ -246,8 +408,21 @@
246 408 return $map['meta'];
247 409 }
248 410
249 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 + /**
250 425 * Renders a preview of the Recurring Payment button, which is not hooked
251 426 * up to the subscription url. Used to preview the block on the frontend
252 427 * for site editors when Stripe has not been connected.
253 428 *
@@ -276,12 +451,12 @@
276 451 *
277 452 * @return boolean
278 453 */
279 454 public function should_render_button_preview( $block ) {
280 - $user_can_edit = $this->user_can_edit();
281 - $requires_stripe_connection = ! $this->get_connected_account_id();
455 + $user_can_edit = static::user_can_edit();
456 + $requires_stripe_connection = ! static::has_connected_account();
282 457
283 - $requires_upgrade = ! self::is_supported_jetpack_recurring_payments();
458 + $jetpack_ready = ! self::is_enabled_jetpack_recurring_payments();
284 459
285 460 $is_premium_content_child = false;
286 461 if ( isset( $block ) && isset( $block->context['isPremiumContentChild'] ) ) {
287 462 $is_premium_content_child = (int) $block->context['isPremiumContentChild'];
@@ -286,13 +461,12 @@
286 461 if ( isset( $block ) && isset( $block->context['isPremiumContentChild'] ) ) {
287 462 $is_premium_content_child = (int) $block->context['isPremiumContentChild'];
288 463 }
289 464
290 - return (
291 - $is_premium_content_child &&
465 + return $is_premium_content_child &&
292 466 $user_can_edit &&
293 - ( $requires_upgrade || $requires_stripe_connection )
294 - );
467 + $requires_stripe_connection &&
468 + $jetpack_ready;
295 469 }
296 470
297 471 /**
298 472 * Callback that parses the membership purchase shortcode.
@@ -300,38 +474,68 @@
300 474 * @param array $attributes - attributes in the shortcode. `id` here is the CPT id of the plan.
301 475 * @param string $content - Recurring Payment block content.
302 476 * @param WP_Block $block - Recurring Payment block instance.
303 477 *
304 - * @return string|void
478 + * @return string|void - HTML for the button, void removes the button.
305 479 */
306 480 public function render_button( $attributes, $content = null, $block = null ) {
307 - 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 );
308 482
309 483 if ( $this->should_render_button_preview( $block ) ) {
310 484 return $this->render_button_preview( $attributes, $content );
311 485 }
312 486
313 - if ( empty( $attributes['planId'] ) ) {
314 - 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' ) ) );
315 489 }
316 490
317 - $plan_id = (int) $attributes['planId'];
318 - $product = get_post( $plan_id );
319 - if ( ! $product || is_wp_error( $product ) ) {
320 - 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'] );
321 498 }
322 - 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 ) ) {
323 524 return;
324 525 }
526 + $plan_id = implode( '+', $valid_plans );
325 527
326 - add_thickbox();
327 -
328 528 if ( ! empty( $content ) ) {
329 529 $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
330 530 $content = str_replace( 'recurring-payments-id', $block_id, $content );
331 531 $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
332 532 $subscribe_url = $this->get_subscription_url( $plan_id );
333 - 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;
334 538 }
335 539
336 540 return $this->deprecated_render_button_v1( $attributes, $plan_id );
337 541 }
@@ -336,8 +540,45 @@
336 540 return $this->deprecated_render_button_v1( $attributes, $plan_id );
337 541 }
338 542
339 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 + /**
340 581 * Builds subscription URL for this membership using the current blog and
341 582 * supplied plan IDs.
342 583 *
343 584 * @param integer $plan_id - Unique ID for the plan being subscribed to.
@@ -366,11 +607,9 @@
366 607 *
367 608 * @return string
368 609 */
369 610 public function deprecated_render_button_v1( $attrs, $plan_id ) {
370 - $button_label = isset( $attrs['submitButtonText'] )
371 - ? $attrs['submitButtonText']
372 - : __( 'Your contribution', 'jetpack' );
611 + $button_label = $attrs['submitButtonText'] ?? __( 'Your contribution', 'jetpack' );
373 612
374 613 $button_styles = array();
375 614 if ( ! empty( $attrs['customBackgroundButtonColor'] ) ) {
376 615 array_push(
@@ -392,9 +631,9 @@
392 631 }
393 632 $button_styles = implode( ';', $button_styles );
394 633
395 634 return sprintf(
396 - '<div class="%1$s"><a role="button" %6$s href="%2$s" class="%3$s" style="%4$s">%5$s</a></div>',
635 + '<div class="%1$s"><a role="button" href="%2$s" class="%3$s" style="%4$s">%5$s</a></div>',
397 636 esc_attr(
398 637 Blocks::classes(
399 638 self::$button_block_name,
400 639 $attrs,
@@ -403,10 +642,9 @@
403 642 ),
404 643 esc_url( $this->get_subscription_url( $plan_id ) ),
405 644 isset( $attrs['submitButtonClasses'] ) ? esc_attr( $attrs['submitButtonClasses'] ) : 'wp-block-button__link',
406 645 esc_attr( $button_styles ),
407 - wp_kses( $button_label, self::$tags_allowed_in_the_button ),
408 - isset( $attrs['submitButtonAttributes'] ) ? sanitize_text_field( $attrs['submitButtonAttributes'] ) : '' // Needed for arbitrary target=_blank on WPCOM VIP.
646 + wp_kses( $button_label, self::$tags_allowed_in_the_button )
409 647 );
410 648 }
411 649
412 650 /**
@@ -424,24 +662,97 @@
424 662
425 663 /**
426 664 * Get the id of the connected payment acount (Stripe etc).
427 665 *
428 - * @return int|void
666 + * @return bool
429 667 */
430 - public static function get_connected_account_id() {
431 - return get_option( self::$connected_account_id_option_name );
668 + public static function has_connected_account() {
669 +
670 + // This is the primary solution.
671 + $has_option = get_option( self::$has_connected_account_option_name, false ) ? true : false;
672 + if ( $has_option ) {
673 + return true;
674 + }
675 +
676 + return false;
432 677 }
433 678
434 679 /**
435 - * Get the newsletter access level
680 + * Get the post access level
436 681 *
437 - * @return string|void
682 + * If no ID is provided, the method tries to get it from the global post object.
683 + *
684 + * @param int|null $post_id The ID of the post. Default is null.
685 + *
686 + * @return string the actual post access level (see projects/plugins/jetpack/extensions/blocks/subscriptions/constants.js for the values).
438 687 */
439 - public static function get_newsletter_access_level() {
440 - return get_post_meta( get_the_ID(), self::$newsletter_access_level_meta_name, true );
688 + public static function get_post_access_level( $post_id = null ) {
689 + if ( ! $post_id ) {
690 + $post_id = get_the_ID();
691 + }
692 + if ( ! $post_id ) {
693 + return Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
694 + }
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 +
703 + $post_access_level = get_post_meta( $post_id, self::$post_access_level_meta_name, true );
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;
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 +
725 + return $post_access_level;
441 726 }
442 727
443 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 + /**
444 755 * Determines whether the current user can edit.
445 756 *
446 757 * @return bool Whether the user can edit.
447 758 */
@@ -446,43 +757,122 @@
446 757 * @return bool Whether the user can edit.
447 758 */
448 759 public static function user_can_edit() {
449 760 $user = wp_get_current_user();
450 - // phpcs:ignore ImportDetection.Imports.RequireImports.Symbol
451 761 return 0 !== $user->ID && current_user_can( 'edit_post', get_the_ID() );
452 762 }
453 763
454 764 /**
765 + * Clears the static cache for all users or for a given user.
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).
785 + * @return bool Whether the post can be viewed
786 + */
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 + }
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() {
815 + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
816 + $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
817 + return $subscription_service->is_current_user_pending_subscriber();
818 + }
819 +
820 + /**
455 821 * Determines whether the current user can view the post based on the newsletter access level
456 822 * and caches the result.
457 823 *
824 + * @param int|null $post_id Explicit post id to check against.
825 + *
458 826 * @return bool Whether the post can be viewed
459 827 */
460 - public static function user_can_view_post() {
461 - $user_id = get_current_user_id();
462 - $post_id = get_the_ID() || 0;
828 + public static function user_can_view_post( $post_id = null ) {
829 + $user_id = get_current_user_id();
830 + if ( null === $post_id ) {
831 + $post_id = get_the_ID();
832 + }
833 +
834 + if ( false === $post_id ) {
835 + $post_id = 0;
836 + }
837 +
463 838 $cache_key = sprintf( '%d_%d', $user_id, $post_id );
464 -
465 839 if ( isset( self::$user_can_view_post_cache[ $cache_key ] ) ) {
466 840 return self::$user_can_view_post_cache[ $cache_key ];
467 841 }
468 842
469 - $value = self::user_can_view_post_nocache();
470 - self::$user_can_view_post_cache[ $cache_key ] = $value;
471 - return $value;
472 - }
843 + $post_access_level = self::get_post_access_level( $post_id );
844 + if ( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
845 + self::$user_can_view_post_cache[ $cache_key ] = true;
846 + return true;
847 + }
473 848
474 - /**
475 - * Determines whether the post can be viewed based on the newsletter access level
476 - *
477 - * @return bool Whether the post can be viewed
478 - */
479 - public static function user_can_view_post_nocache() {
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;
853 + }
854 +
480 855 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
856 + $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
481 857
482 - $newsletter_access_level = self::get_newsletter_access_level();
483 - $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
484 - return $paywall->visitor_can_view_content( self::get_all_plans_id_jetpack_recurring_payments(), $newsletter_access_level );
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 +
873 + self::$user_can_view_post_cache[ $cache_key ] = $can_view_post;
874 + return $can_view_post;
485 875 }
486 876
487 877 /**
488 878 * Whether Recurring Payments are enabled. True if the block
@@ -491,25 +881,28 @@
491 881 *
492 882 * @return bool
493 883 */
494 884 public static function is_enabled_jetpack_recurring_payments() {
495 - return (
496 - self::is_supported_jetpack_recurring_payments() ||
497 - (
498 - Jetpack::is_connection_ready() &&
499 - /** This filter is documented in class.jetpack-gutenberg.php */
500 - ! apply_filters( 'jetpack_block_editor_enable_upgrade_nudge', false )
501 - )
502 - );
885 + $api_available = ( new Host() )->is_wpcom_simple() || Jetpack::is_connection_ready();
886 + return $api_available;
503 887 }
504 888
505 889 /**
506 - * Whether the site's plan supports the Recurring Payments block.
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
507 894 */
508 - public static function is_supported_jetpack_recurring_payments() {
509 - $api_available = ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Jetpack::is_connection_ready() );
510 - $supported_in_plan = Jetpack_Plan::supports( 'recurring-payments' );
511 - return apply_filters( 'test_jetpack_is_supported_jetpack_recurring_payments', $api_available && $supported_in_plan );
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;
512 905 }
513 906
514 907 /**
515 908 * Whether site has any paid plan.
@@ -518,9 +911,9 @@
518 911 *
519 912 * @return bool
520 913 */
521 914 public static function has_configured_plans_jetpack_recurring_payments( $type = '' ) {
522 - if ( ! self::is_supported_jetpack_recurring_payments() ) {
915 + if ( ! self::is_enabled_jetpack_recurring_payments() ) {
523 916 return false;
524 917 }
525 918 $query = array(
526 919 'post_type' => self::$post_type_plan,
@@ -533,27 +926,94 @@
533 926 $query['meta_value'] = true;
534 927 }
535 928
536 929 $plans = get_posts( $query );
537 - return ( count( $plans ) > 0 );
930 + return ( is_countable( $plans ) && count( $plans ) > 0 );
538 931 }
539 932
540 933 /**
541 - * Return all plans
934 + * Return the list of plan posts
542 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 + *
543 966 * @return array
544 967 */
545 - public static function get_all_plans_id_jetpack_recurring_payments() {
546 - if ( ! self::is_supported_jetpack_recurring_payments() ) {
968 + public static function get_all_newsletter_plan_ids( $allow_deleted = true ) {
969 +
970 + if ( ! self::is_enabled_jetpack_recurring_payments() ) {
547 971 return array();
548 972 }
549 - return get_posts(
550 - array(
551 - 'post_type' => self::$post_type_plan,
552 - 'posts_per_page' => -1,
553 - 'fields' => 'ids',
554 - )
555 - );
973 +
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 + }
556 1016 }
557 1017
558 1018 /**
559 1019 * Register the Recurring Payments Gutenberg block
@@ -569,11 +1029,12 @@
569 1029 if ( self::is_enabled_jetpack_recurring_payments() ) {
570 1030 Blocks::jetpack_register_block(
571 1031 'jetpack/recurring-payments',
572 1032 array(
573 - 'render_callback' => array( $this, 'render_button' ),
574 - 'uses_context' => array( 'isPremiumContentChild' ),
575 - '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(
576 1037 'jetpack/parentBlockWidth' => 'width',
577 1038 ),
578 1039 )
579 1040 );
@@ -578,9 +1039,9 @@
578 1039 )
579 1040 );
580 1041 } else {
581 1042 Jetpack_Gutenberg::set_extension_unavailable(
582 - 'jetpack/recurring-payments',
1043 + 'recurring-payments',
583 1044 'missing_plan',
584 1045 array(
585 1046 'required_feature' => 'memberships',
586 1047 'required_plan' => self::$required_plan,
@@ -588,7 +1049,88 @@
588 1049 );
589 1050 }
590 1051
591 1052 self::$has_registered_block = true;
1053 + }
1054 +
1055 + /**
1056 + * Transforms a number into it's short human-readable version.
1057 + *
1058 + * @param int $subscribers_total The extrapolated excerpt string.
1059 + *
1060 + * @return string Human-readable version of the number. ie. 1.9 M.
1061 + */
1062 + public static function get_join_others_text( $subscribers_total ) {
1063 + if ( $subscribers_total >= 1000000 ) {
1064 + /* translators: %s: number of folks following the blog, millions(M) with one decimal. i.e. 1.1 */
1065 + return sprintf( __( 'Join %sM other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000000, 1 ) ) );
1066 + }
1067 + if ( $subscribers_total >= 10000 ) {
1068 + /* translators: %s: number of folks following the blog, thousands(K) with one decimal. i.e. 1.1 */
1069 + return sprintf( __( 'Join %sK other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000, 1 ) ) );
1070 + }
1071 +
1072 + /* translators: %s: number of folks following the blog */
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 );
592 1134 }
593 1135 }
594 1136 Jetpack_Memberships::get_instance();