PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 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 All 508 releases
← All changes | modules/memberships/class-jetpack-memberships.php +635 -63 12.4.2 → 16.3 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
@@ -27,23 +36,45 @@
27 36 *
28 37 * @var string
29 38 */
30 39 public static $post_type_plan = 'jp_mem_plan';
40 +
31 41 /**
32 - * Option that will store currently set up account (Stripe etc) id for memberships.
42 + * Our CPT type for the product (plan).
33 43 *
34 44 * @var string
35 45 */
36 - public static $connected_account_id_option_name = 'jetpack-memberships-connected-account-id';
46 + public static $post_type_coupon = 'memberships_coupon';
37 47
38 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 + /**
39 63 * Post meta that will store the level of access for newsletters
40 64 *
41 65 * @var string
42 66 */
43 - 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;
44 68
45 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 + /**
46 77 * Button block type to use.
47 78 *
48 79 * @var string
49 80 */
@@ -56,8 +87,33 @@
56 87 */
57 88 private static $tags_allowed_in_the_button = array( 'br' => array() );
58 89
59 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 + /**
60 116 * The minimum required plan for this Gutenberg block.
61 117 *
62 118 * @var string Plan slug
63 119 */
@@ -84,14 +140,34 @@
84 140 */
85 141 private static $user_can_view_post_cache = array();
86 142
87 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 + /**
88 165 * Currencies we support and Stripe's minimum amount for a transaction in that currency.
89 166 *
90 167 * @link https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
91 168 *
92 - * List has to be in with `SUPPORTED_CURRENCIES` in extensions/shared/currencies.js and
93 - * `Memberships_Product::SUPPORTED_CURRENCIES` in the WP.com memberships library.
169 + * List has to be in with `SUPPORTED_CURRENCIES` in extensions/shared/currencies.js.
94 170 */
95 171 const SUPPORTED_CURRENCIES = array(
96 172 'USD' => 0.5,
97 173 'AUD' => 0.5,
@@ -109,8 +185,17 @@
109 185 'NZD' => 0.5,
110 186 'PLN' => 2.0,
111 187 'SEK' => 3.0,
112 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,
113 198 );
114 199
115 200 /**
116 201 * Jetpack_Memberships constructor.
@@ -127,9 +212,9 @@
127 212 self::$instance = new self();
128 213 self::$instance->register_init_hook();
129 214 // Yes, `pro-plan` with a dash, `jetpack_personal` with an underscore. Check the v1.5 endpoint to verify.
130 215 $wpcom_plan_slug = defined( 'ENABLE_PRO_PLAN' ) ? 'pro-plan' : 'personal-bundle';
131 - 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';
132 217 }
133 218
134 219 return self::$instance;
135 220 }
@@ -150,8 +235,20 @@
150 235 ),
151 236 'site_subscriber' => array(
152 237 'meta' => $meta_prefix . 'site_subscriber',
153 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 + ),
154 251 );
155 252 return $properties;
156 253 }
157 254
@@ -160,8 +257,10 @@
160 257 */
161 258 private function register_init_hook() {
162 259 add_action( 'init', array( $this, 'init_hook_action' ) );
163 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' ) );
164 263 }
165 264
166 265 /**
167 266 * Actual hooks initializing on init.
@@ -169,11 +268,26 @@
169 268 public function init_hook_action() {
170 269 add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
171 270 add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
172 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 + }
173 276 }
174 277
175 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 + /**
176 290 * Sets up the custom post types for the module.
177 291 */
178 292 private function setup_cpts() {
179 293 /*
@@ -206,8 +320,27 @@
206 320 'capabilities' => $capabilities,
207 321 'show_in_rest' => false,
208 322 );
209 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 );
210 343 }
211 344
212 345 /**
213 346 * Allows custom post types to be used by REST API.
@@ -218,8 +351,9 @@
218 351 * @return array
219 352 */
220 353 public function allow_rest_api_types( $post_types ) {
221 354 $post_types[] = self::$post_type_plan;
355 + $post_types[] = self::$post_type_coupon;
222 356
223 357 return $post_types;
224 358 }
225 359
@@ -230,13 +364,37 @@
230 364 *
231 365 * @return array
232 366 */
233 367 public function allow_sync_post_meta( $post_meta ) {
234 - $meta_keys = array_map(
368 + $meta_keys_plans = array_map(
235 369 array( $this, 'return_meta' ),
236 370 self::get_plan_property_mapping()
237 371 );
238 - 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 + );
239 397 }
240 398
241 399 /**
242 400 * This returns meta attribute of passet array.
@@ -250,8 +408,21 @@
250 408 return $map['meta'];
251 409 }
252 410
253 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 + /**
254 425 * Renders a preview of the Recurring Payment button, which is not hooked
255 426 * up to the subscription url. Used to preview the block on the frontend
256 427 * for site editors when Stripe has not been connected.
257 428 *
@@ -281,9 +452,9 @@
281 452 * @return boolean
282 453 */
283 454 public function should_render_button_preview( $block ) {
284 455 $user_can_edit = static::user_can_edit();
285 - $requires_stripe_connection = ! static::get_connected_account_id();
456 + $requires_stripe_connection = ! static::has_connected_account();
286 457
287 458 $jetpack_ready = ! self::is_enabled_jetpack_recurring_payments();
288 459
289 460 $is_premium_content_child = false;
@@ -291,11 +462,11 @@
291 462 $is_premium_content_child = (int) $block->context['isPremiumContentChild'];
292 463 }
293 464
294 465 return $is_premium_content_child &&
295 - $user_can_edit &&
296 - $requires_stripe_connection &&
297 - $jetpack_ready;
466 + $user_can_edit &&
467 + $requires_stripe_connection &&
468 + $jetpack_ready;
298 469 }
299 470
300 471 /**
301 472 * Callback that parses the membership purchase shortcode.
@@ -303,38 +474,68 @@
303 474 * @param array $attributes - attributes in the shortcode. `id` here is the CPT id of the plan.
304 475 * @param string $content - Recurring Payment block content.
305 476 * @param WP_Block $block - Recurring Payment block instance.
306 477 *
307 - * @return string|void
478 + * @return string|void - HTML for the button, void removes the button.
308 479 */
309 480 public function render_button( $attributes, $content = null, $block = null ) {
310 - 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 );
311 482
312 483 if ( $this->should_render_button_preview( $block ) ) {
313 484 return $this->render_button_preview( $attributes, $content );
314 485 }
315 486
316 - if ( empty( $attributes['planId'] ) ) {
317 - 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' ) ) );
318 489 }
319 490
320 - $plan_id = (int) $attributes['planId'];
321 - $product = get_post( $plan_id );
322 - if ( ! $product || is_wp_error( $product ) ) {
323 - 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'] );
324 498 }
325 - 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 ) ) {
326 524 return;
327 525 }
526 + $plan_id = implode( '+', $valid_plans );
328 527
329 - add_thickbox();
330 -
331 528 if ( ! empty( $content ) ) {
332 529 $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
333 530 $content = str_replace( 'recurring-payments-id', $block_id, $content );
334 531 $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
335 532 $subscribe_url = $this->get_subscription_url( $plan_id );
336 - 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;
337 538 }
338 539
339 540 return $this->deprecated_render_button_v1( $attributes, $plan_id );
340 541 }
@@ -339,8 +540,45 @@
339 540 return $this->deprecated_render_button_v1( $attributes, $plan_id );
340 541 }
341 542
342 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 + /**
343 581 * Builds subscription URL for this membership using the current blog and
344 582 * supplied plan IDs.
345 583 *
346 584 * @param integer $plan_id - Unique ID for the plan being subscribed to.
@@ -369,11 +607,9 @@
369 607 *
370 608 * @return string
371 609 */
372 610 public function deprecated_render_button_v1( $attrs, $plan_id ) {
373 - $button_label = isset( $attrs['submitButtonText'] )
374 - ? $attrs['submitButtonText']
375 - : __( 'Your contribution', 'jetpack' );
611 + $button_label = $attrs['submitButtonText'] ?? __( 'Your contribution', 'jetpack' );
376 612
377 613 $button_styles = array();
378 614 if ( ! empty( $attrs['customBackgroundButtonColor'] ) ) {
379 615 array_push(
@@ -395,9 +631,9 @@
395 631 }
396 632 $button_styles = implode( ';', $button_styles );
397 633
398 634 return sprintf(
399 - '<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>',
400 636 esc_attr(
401 637 Blocks::classes(
402 638 self::$button_block_name,
403 639 $attrs,
@@ -406,10 +642,9 @@
406 642 ),
407 643 esc_url( $this->get_subscription_url( $plan_id ) ),
408 644 isset( $attrs['submitButtonClasses'] ) ? esc_attr( $attrs['submitButtonClasses'] ) : 'wp-block-button__link',
409 645 esc_attr( $button_styles ),
410 - wp_kses( $button_label, self::$tags_allowed_in_the_button ),
411 - 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 )
412 647 );
413 648 }
414 649
415 650 /**
@@ -427,33 +662,97 @@
427 662
428 663 /**
429 664 * Get the id of the connected payment acount (Stripe etc).
430 665 *
431 - * @return int|void
666 + * @return bool
432 667 */
433 - public static function get_connected_account_id() {
434 - 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;
435 677 }
436 678
437 679 /**
438 680 * Get the post access level
439 681 *
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 + *
440 686 * @return string the actual post access level (see projects/plugins/jetpack/extensions/blocks/subscriptions/constants.js for the values).
441 687 */
442 - public static function get_post_access_level() {
443 - $post_id = get_the_ID();
688 + public static function get_post_access_level( $post_id = null ) {
444 689 if ( ! $post_id ) {
445 - return Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
690 + $post_id = get_the_ID();
446 691 }
692 + if ( ! $post_id ) {
693 + return Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
694 + }
447 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 +
448 703 $post_access_level = get_post_meta( $post_id, self::$post_access_level_meta_name, true );
449 - if ( empty( $post_access_level ) ) {
450 - $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;
451 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 +
452 725 return $post_access_level;
453 726 }
454 727
455 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 + /**
456 755 * Determines whether the current user can edit.
457 756 *
458 757 * @return bool Whether the user can edit.
459 758 */
@@ -458,41 +757,150 @@
458 757 * @return bool Whether the user can edit.
459 758 */
460 759 public static function user_can_edit() {
461 760 $user = wp_get_current_user();
462 - // phpcs:ignore ImportDetection.Imports.RequireImports.Symbol
463 761 return 0 !== $user->ID && current_user_can( 'edit_post', get_the_ID() );
464 762 }
465 763
466 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 + /**
467 821 * Determines whether the current user can view the post based on the newsletter access level
468 822 * and caches the result.
469 823 *
824 + * @param int|null $post_id Explicit post id to check against.
825 + *
470 826 * @return bool Whether the post can be viewed
471 827 */
472 - public static function user_can_view_post() {
828 + public static function user_can_view_post( $post_id = null ) {
829 + return self::check_post_access( $post_id, true );
830 + }
831 +
832 + /**
833 + * Check the post's subscription requirement without granting access for editing it.
834 + *
835 + * @since $$next-version$$
836 + *
837 + * @param int|null $post_id Explicit post ID, or the loop post when omitted.
838 + * @return bool Whether the visitor meets the post's subscription requirement.
839 + */
840 + public static function user_has_subscription_access( $post_id = null ) {
841 + return self::check_post_access( $post_id, false );
842 + }
843 +
844 + /**
845 + * Evaluate and cache post access with or without the editorial exception.
846 + *
847 + * @param int|null $post_id Post to check.
848 + * @param bool $allow_editor_access Whether editing the post can grant access.
849 + * @return bool Whether access is granted.
850 + */
851 + private static function check_post_access( $post_id, $allow_editor_access ) {
473 852 $user_id = get_current_user_id();
474 - $post_id = get_the_ID();
853 + if ( null === $post_id ) {
854 + $post_id = get_the_ID();
855 + }
475 856
476 857 if ( false === $post_id ) {
477 858 $post_id = 0;
478 859 }
479 860
480 - $cache_key = sprintf( '%d_%d', $user_id, $post_id );
861 + $cache_key = sprintf( '%d_%d_%d', $user_id, $post_id, (int) $allow_editor_access );
481 862 if ( isset( self::$user_can_view_post_cache[ $cache_key ] ) ) {
482 863 return self::$user_can_view_post_cache[ $cache_key ];
483 864 }
484 865
485 - $post_access_level = self::get_post_access_level();
486 - if ( Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
866 + $post_access_level = self::get_post_access_level( $post_id );
867 + if ( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
487 868 self::$user_can_view_post_cache[ $cache_key ] = true;
488 869 return true;
489 870 }
490 871
872 + // we are sending the post to subscribers so the user is a subscriber
873 + if ( $allow_editor_access && defined( 'WPCOM_SENDING_POST_TO_SUBSCRIBERS' ) && WPCOM_SENDING_POST_TO_SUBSCRIBERS && Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS === $post_access_level ) {
874 + self::$user_can_view_post_cache[ $cache_key ] = true;
875 + return true;
876 + }
877 +
491 878 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
492 - $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
493 - $can_view_post = $paywall->visitor_can_view_content( self::get_all_newsletter_plan_ids(), $post_access_level );
879 + $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
494 880
881 + $all_newsletters_plan_ids = self::get_all_newsletter_plan_ids();
882 +
883 + if ( 0 === count( $all_newsletters_plan_ids ) &&
884 + (
885 + Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS === $post_access_level ||
886 + Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS === $post_access_level
887 + )
888 + ) {
889 + // The post is paywalled but there is no newsletter plans on the site.
890 + // We downgrade the post level to subscribers-only
891 + $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS;
892 + }
893 +
894 + // Pass the post explicitly: callers outside the loop have no get_the_ID() to fall back on.
895 + if ( $allow_editor_access ) {
896 + // @phan-suppress-next-line PhanParamTooMany -- Concrete services accept the optional $post_id; interface omits it on purpose.
897 + $can_view_post = $paywall->visitor_can_view_content( $all_newsletters_plan_ids, $post_access_level, $post_id );
898 + } else {
899 + $can_view_post = is_callable( array( $paywall, 'visitor_has_subscription_access' ) )
900 + && $paywall->visitor_has_subscription_access( $all_newsletters_plan_ids, $post_access_level, $post_id );
901 + }
902 +
495 903 self::$user_can_view_post_cache[ $cache_key ] = $can_view_post;
496 904 return $can_view_post;
497 905 }
498 906
@@ -503,13 +911,31 @@
503 911 *
504 912 * @return bool
505 913 */
506 914 public static function is_enabled_jetpack_recurring_payments() {
507 - $api_available = ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Jetpack::is_connection_ready() );
915 + $api_available = ( new Host() )->is_wpcom_simple() || Jetpack::is_connection_ready();
508 916 return $api_available;
509 917 }
510 918
511 919 /**
920 + * Whether to enable the blocks in the editor.
921 + * All Monetize blocks (except Simple Payments) need a user with at least `edit_posts` capability
922 + *
923 + * @return bool
924 + */
925 + public static function should_enable_monetize_blocks_in_editor() {
926 + if ( ! is_admin() ) {
927 + // We enable the block for the front-end in all cases
928 + return true;
929 +
930 + }
931 +
932 + $is_offline_mode = ( new Status() )->is_offline_mode();
933 + $enable_monetize_blocks_in_editor = ( new Host() )->is_wpcom_simple() || ( ! $is_offline_mode );
934 + return $enable_monetize_blocks_in_editor;
935 + }
936 +
937 + /**
512 938 * Whether site has any paid plan.
513 939 *
514 940 * @param string $type - Type of a plan for which site is configured. For now supports empty and newsletter.
515 941 *
@@ -534,26 +960,90 @@
534 960 return ( is_countable( $plans ) && count( $plans ) > 0 );
535 961 }
536 962
537 963 /**
538 - * Return membership plans
964 + * Return the list of plan posts
539 965 *
966 + * @return WP_Post[]|WP_Error
967 + */
968 + public static function get_all_plans() {
969 + if ( ! self::is_enabled_jetpack_recurring_payments() ) {
970 + return array();
971 + }
972 +
973 + // We can retrieve the data directly except on a Jetpack/Atomic cached site or
974 + $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
975 + if ( ! $is_cached_site ) {
976 + return get_posts(
977 + array(
978 + 'posts_per_page' => -1,
979 + 'post_type' => self::$post_type_plan,
980 + )
981 + );
982 + } else {
983 + // On cached site on WPCOM
984 + require_lib( 'memberships' );
985 + return Memberships_Product::get_plans_posts_list( get_current_blog_id() );
986 + }
987 + }
988 +
989 + /**
990 + * Return all membership plans ids (deleted or not)
991 + * This function is used both on WPCOM or on Jetpack self-hosted.
992 + * Depending on the environment we need to mitigate where the data is retrieved from.
993 + *
994 + * @param bool $allow_deleted Whether to allow deleted plans to be returned. Defaults to true.
995 + *
540 996 * @return array
541 997 */
542 - public static function get_all_newsletter_plan_ids() {
998 + public static function get_all_newsletter_plan_ids( $allow_deleted = true ) {
999 +
543 1000 if ( ! self::is_enabled_jetpack_recurring_payments() ) {
544 1001 return array();
545 1002 }
546 1003
547 - return get_posts(
548 - array(
549 - 'posts_per_page' => -1,
550 - 'fields' => 'ids',
551 - 'meta_value' => true,
552 - 'post_type' => self::$post_type_plan,
553 - 'meta_key' => 'jetpack_memberships_site_subscriber',
554 - )
555 - );
1004 + // We can retrieve the data directly except on a Jetpack/Atomic cached site or
1005 + $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
1006 + if ( ! $is_cached_site ) {
1007 + $meta_query = array(
1008 + array(
1009 + 'key' => 'jetpack_memberships_type',
1010 + 'value' => self::$type_tier,
1011 + ),
1012 + );
1013 +
1014 + if ( $allow_deleted === false ) {
1015 + $meta_query[] = array(
1016 + 'key' => 'jetpack_memberships_is_deleted',
1017 + 'compare' => 'NOT EXISTS',
1018 + );
1019 + }
1020 +
1021 + return get_posts(
1022 + array(
1023 + 'posts_per_page' => -1,
1024 + 'fields' => 'ids',
1025 + 'post_type' => self::$post_type_plan,
1026 + 'meta_query' => $meta_query,
1027 + )
1028 + );
1029 +
1030 + } else {
1031 + // On cached site on WPCOM
1032 + require_lib( 'memberships' );
1033 + $list = Memberships_Product::get_product_list( get_current_blog_id(), self::$type_tier, null, $allow_deleted );
1034 +
1035 + if ( is_wp_error( $list ) ) {
1036 + return array();
1037 + }
1038 +
1039 + return array_map(
1040 + function ( $product ) {
1041 + return $product['id'];
1042 + }, // Returning only post ids
1043 + $list
1044 + );
1045 + }
556 1046 }
557 1047
558 1048 /**
559 1049 * Register the Recurring Payments Gutenberg block
@@ -569,11 +1059,12 @@
569 1059 if ( self::is_enabled_jetpack_recurring_payments() ) {
570 1060 Blocks::jetpack_register_block(
571 1061 'jetpack/recurring-payments',
572 1062 array(
573 - 'render_callback' => array( $this, 'render_button' ),
574 - 'uses_context' => array( 'isPremiumContentChild' ),
575 - 'provides_context' => array(
1063 + 'render_callback' => array( $this, 'render_button' ),
1064 + 'render_email_callback' => array( $this, 'render_button_email' ),
1065 + 'uses_context' => array( 'isPremiumContentChild' ),
1066 + 'provides_context' => array(
576 1067 'jetpack/parentBlockWidth' => 'width',
577 1068 ),
578 1069 )
579 1070 );
@@ -578,9 +1069,9 @@
578 1069 )
579 1070 );
580 1071 } else {
581 1072 Jetpack_Gutenberg::set_extension_unavailable(
582 - 'jetpack/recurring-payments',
1073 + 'recurring-payments',
583 1074 'missing_plan',
584 1075 array(
585 1076 'required_feature' => 'memberships',
586 1077 'required_plan' => self::$required_plan,
@@ -588,7 +1079,88 @@
588 1079 );
589 1080 }
590 1081
591 1082 self::$has_registered_block = true;
1083 + }
1084 +
1085 + /**
1086 + * Transforms a number into it's short human-readable version.
1087 + *
1088 + * @param int $subscribers_total The extrapolated excerpt string.
1089 + *
1090 + * @return string Human-readable version of the number. ie. 1.9 M.
1091 + */
1092 + public static function get_join_others_text( $subscribers_total ) {
1093 + if ( $subscribers_total >= 1000000 ) {
1094 + /* translators: %s: number of folks following the blog, millions(M) with one decimal. i.e. 1.1 */
1095 + return sprintf( __( 'Join %sM other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000000, 1 ) ) );
1096 + }
1097 + if ( $subscribers_total >= 10000 ) {
1098 + /* translators: %s: number of folks following the blog, thousands(K) with one decimal. i.e. 1.1 */
1099 + return sprintf( __( 'Join %sK other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000, 1 ) ) );
1100 + }
1101 +
1102 + /* translators: %s: number of folks following the blog */
1103 + return sprintf( _n( 'Join %s other subscriber', 'Join %s other subscribers', $subscribers_total, 'jetpack' ), number_format_i18n( $subscribers_total ) );
1104 + }
1105 +
1106 + /**
1107 + * Returns the email of the current user.
1108 + *
1109 + * @return string
1110 + */
1111 + public static function get_current_user_email() {
1112 + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1113 + $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1114 + return $subscription_service->get_subscriber_email();
1115 + }
1116 +
1117 + /**
1118 + * Returns if the current user is subscribed or not.
1119 + *
1120 + * @return boolean
1121 + */
1122 + public static function is_current_user_subscribed() {
1123 + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1124 + $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1125 + return $subscription_service->is_current_user_subscribed();
1126 + }
1127 +
1128 + /**
1129 + * Render a tier description (stored as markdown text) to safe HTML.
1130 + *
1131 + * Uses Jetpack's markdown parser, restores paragraph structure (the parser
1132 + * strips <p> tags expecting wpautop to run later), forces links to open in a
1133 + * new tab (descriptions are shown inside the subscribe modal's iframe), and
1134 + * finally sanitizes the output to a small tag allowlist.
1135 + *
1136 + * @param mixed $description Raw tier description (markdown text). Non-scalar
1137 + * values are treated as empty.
1138 + * @return string Sanitized HTML, or an empty string for an empty description.
1139 + */
1140 + public static function render_tier_description_html( $description ) {
1141 + if ( ! is_scalar( $description ) ) {
1142 + return '';
1143 + }
1144 + $description = (string) $description;
1145 + if ( '' === trim( $description ) ) {
1146 + return '';
1147 + }
1148 +
1149 + if ( ! class_exists( 'WPCom_Markdown' ) ) {
1150 + require_once JETPACK__PLUGIN_DIR . 'modules/markdown/easy-markdown.php';
1151 + }
1152 +
1153 + $html = WPCom_Markdown::get_instance()->transform(
1154 + $description,
1155 + array(
1156 + 'unslash' => false,
1157 + 'id' => false,
1158 + )
1159 + );
1160 + $html = wpautop( $html );
1161 + $html = links_add_target( $html, '_blank' );
1162 +
1163 + return wp_kses( $html, self::TIER_DESCRIPTION_ALLOWED_HTML );
592 1164 }
593 1165 }
594 1166 Jetpack_Memberships::get_instance();