PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.5
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 +546 -67 12.6.4 → 16.3-a.5 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 */
@@ -96,8 +140,29 @@
96 140 */
97 141 private static $user_can_view_post_cache = array();
98 142
99 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 + /**
100 165 * Currencies we support and Stripe's minimum amount for a transaction in that currency.
101 166 *
102 167 * @link https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
103 168 *
@@ -120,8 +185,17 @@
120 185 'NZD' => 0.5,
121 186 'PLN' => 2.0,
122 187 'SEK' => 3.0,
123 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,
124 198 );
125 199
126 200 /**
127 201 * Jetpack_Memberships constructor.
@@ -138,9 +212,9 @@
138 212 self::$instance = new self();
139 213 self::$instance->register_init_hook();
140 214 // Yes, `pro-plan` with a dash, `jetpack_personal` with an underscore. Check the v1.5 endpoint to verify.
141 215 $wpcom_plan_slug = defined( 'ENABLE_PRO_PLAN' ) ? 'pro-plan' : 'personal-bundle';
142 - 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';
143 217 }
144 218
145 219 return self::$instance;
146 220 }
@@ -161,8 +235,20 @@
161 235 ),
162 236 'site_subscriber' => array(
163 237 'meta' => $meta_prefix . 'site_subscriber',
164 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 + ),
165 251 );
166 252 return $properties;
167 253 }
168 254
@@ -171,8 +257,10 @@
171 257 */
172 258 private function register_init_hook() {
173 259 add_action( 'init', array( $this, 'init_hook_action' ) );
174 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' ) );
175 263 }
176 264
177 265 /**
178 266 * Actual hooks initializing on init.
@@ -180,11 +268,26 @@
180 268 public function init_hook_action() {
181 269 add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
182 270 add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
183 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 + }
184 276 }
185 277
186 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 + /**
187 290 * Sets up the custom post types for the module.
188 291 */
189 292 private function setup_cpts() {
190 293 /*
@@ -217,8 +320,27 @@
217 320 'capabilities' => $capabilities,
218 321 'show_in_rest' => false,
219 322 );
220 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 );
221 343 }
222 344
223 345 /**
224 346 * Allows custom post types to be used by REST API.
@@ -229,8 +351,9 @@
229 351 * @return array
230 352 */
231 353 public function allow_rest_api_types( $post_types ) {
232 354 $post_types[] = self::$post_type_plan;
355 + $post_types[] = self::$post_type_coupon;
233 356
234 357 return $post_types;
235 358 }
236 359
@@ -241,13 +364,37 @@
241 364 *
242 365 * @return array
243 366 */
244 367 public function allow_sync_post_meta( $post_meta ) {
245 - $meta_keys = array_map(
368 + $meta_keys_plans = array_map(
246 369 array( $this, 'return_meta' ),
247 370 self::get_plan_property_mapping()
248 371 );
249 - 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 + );
250 397 }
251 398
252 399 /**
253 400 * This returns meta attribute of passet array.
@@ -261,8 +408,21 @@
261 408 return $map['meta'];
262 409 }
263 410
264 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 + /**
265 425 * Renders a preview of the Recurring Payment button, which is not hooked
266 426 * up to the subscription url. Used to preview the block on the frontend
267 427 * for site editors when Stripe has not been connected.
268 428 *
@@ -302,11 +462,11 @@
302 462 $is_premium_content_child = (int) $block->context['isPremiumContentChild'];
303 463 }
304 464
305 465 return $is_premium_content_child &&
306 - $user_can_edit &&
307 - $requires_stripe_connection &&
308 - $jetpack_ready;
466 + $user_can_edit &&
467 + $requires_stripe_connection &&
468 + $jetpack_ready;
309 469 }
310 470
311 471 /**
312 472 * Callback that parses the membership purchase shortcode.
@@ -314,38 +474,68 @@
314 474 * @param array $attributes - attributes in the shortcode. `id` here is the CPT id of the plan.
315 475 * @param string $content - Recurring Payment block content.
316 476 * @param WP_Block $block - Recurring Payment block instance.
317 477 *
318 - * @return string|void
478 + * @return string|void - HTML for the button, void removes the button.
319 479 */
320 480 public function render_button( $attributes, $content = null, $block = null ) {
321 - 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 );
322 482
323 483 if ( $this->should_render_button_preview( $block ) ) {
324 484 return $this->render_button_preview( $attributes, $content );
325 485 }
326 486
327 - if ( empty( $attributes['planId'] ) ) {
328 - 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' ) ) );
329 489 }
330 490
331 - $plan_id = (int) $attributes['planId'];
332 - $product = get_post( $plan_id );
333 - if ( ! $product || is_wp_error( $product ) ) {
334 - 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'] );
335 498 }
336 - 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 ) ) {
337 524 return;
338 525 }
526 + $plan_id = implode( '+', $valid_plans );
339 527
340 - add_thickbox();
341 -
342 528 if ( ! empty( $content ) ) {
343 529 $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
344 530 $content = str_replace( 'recurring-payments-id', $block_id, $content );
345 531 $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
346 532 $subscribe_url = $this->get_subscription_url( $plan_id );
347 - 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;
348 538 }
349 539
350 540 return $this->deprecated_render_button_v1( $attributes, $plan_id );
351 541 }
@@ -350,8 +540,45 @@
350 540 return $this->deprecated_render_button_v1( $attributes, $plan_id );
351 541 }
352 542
353 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 + /**
354 581 * Builds subscription URL for this membership using the current blog and
355 582 * supplied plan IDs.
356 583 *
357 584 * @param integer $plan_id - Unique ID for the plan being subscribed to.
@@ -380,11 +607,9 @@
380 607 *
381 608 * @return string
382 609 */
383 610 public function deprecated_render_button_v1( $attrs, $plan_id ) {
384 - $button_label = isset( $attrs['submitButtonText'] )
385 - ? $attrs['submitButtonText']
386 - : __( 'Your contribution', 'jetpack' );
611 + $button_label = $attrs['submitButtonText'] ?? __( 'Your contribution', 'jetpack' );
387 612
388 613 $button_styles = array();
389 614 if ( ! empty( $attrs['customBackgroundButtonColor'] ) ) {
390 615 array_push(
@@ -447,11 +672,9 @@
447 672 if ( $has_option ) {
448 673 return true;
449 674 }
450 675
451 - // This is the fallback solution.
452 - // TODO: Remove this once the has_connected_account_option is migrated to all sites.
453 - return get_option( 'jetpack-memberships-connected-account-id', false ) ? true : false;
676 + return false;
454 677 }
455 678
456 679 /**
457 680 * Get the post access level
@@ -466,19 +689,61 @@
466 689 if ( ! $post_id ) {
467 690 $post_id = get_the_ID();
468 691 }
469 692 if ( ! $post_id ) {
470 - return Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
693 + return Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
471 694 }
472 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 +
473 703 $post_access_level = get_post_meta( $post_id, self::$post_access_level_meta_name, true );
474 - if ( empty( $post_access_level ) ) {
475 - $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;
476 712 }
713 +
714 + self::$post_access_level_cache[ $cache_key ] = $post_access_level;
715 +
477 716 return $post_access_level;
478 717 }
479 718
480 719 /**
720 + * Get the post tier plan
721 + *
722 + * If no ID is provided, the method tries to get it from the global post object.
723 + *
724 + * @param int|null $post_id The ID of the post. Default is null.
725 + *
726 + * @return WP_Post|null the actual post tier.
727 + */
728 + public static function get_post_tier( $post_id = null ) {
729 + if ( ! $post_id ) {
730 + $post_id = get_the_ID();
731 + }
732 +
733 + if ( ! $post_id ) {
734 + return null;
735 + }
736 +
737 + $post_tier_id = get_post_meta( $post_id, self::$post_access_tier_meta_name, true );
738 + if ( empty( $post_tier_id ) ) {
739 + return null;
740 + }
741 +
742 + return get_post( $post_tier_id );
743 + }
744 +
745 + /**
481 746 * Determines whether the current user can edit.
482 747 *
483 748 * @return bool Whether the user can edit.
484 749 */
@@ -483,21 +748,80 @@
483 748 * @return bool Whether the user can edit.
484 749 */
485 750 public static function user_can_edit() {
486 751 $user = wp_get_current_user();
487 - // phpcs:ignore ImportDetection.Imports.RequireImports.Symbol
488 752 return 0 !== $user->ID && current_user_can( 'edit_post', get_the_ID() );
489 753 }
490 754
491 755 /**
756 + * Clears the static cache for all users or for a given user.
757 + *
758 + * @param int|null $user_id The user_id to unset in the cache, otherwise the entire static cache is cleared.
759 + * @return void
760 + */
761 + public static function clear_cache( ?int $user_id = null ) {
762 + if ( empty( $user_id ) ) {
763 + self::$user_is_paid_subscriber_cache = array();
764 + self::$user_can_view_post_cache = array();
765 + return;
766 + }
767 + unset( self::$user_is_paid_subscriber_cache[ $user_id ] );
768 + unset( self::$user_can_view_post_cache[ $user_id ] );
769 + }
770 +
771 + /**
772 + * Determines whether the current user is a paid subscriber and caches the result.
773 + *
774 + * @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.
775 + * @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).
776 + * @return bool Whether the post can be viewed
777 + */
778 + public static function user_is_paid_subscriber( $valid_plan_ids = array(), $user_id = null ) {
779 + if ( empty( $user_id ) ) {
780 + $user_id = get_current_user_id();
781 + if ( empty( $user_id ) ) {
782 + return false;
783 + }
784 + }
785 + // sort and stringify sorted valid plan ids to use as a cache key
786 + sort( $valid_plan_ids );
787 + $cache_key = $user_id . '_' . implode( ',', $valid_plan_ids );
788 + if ( ! isset( self::$user_is_paid_subscriber_cache[ $cache_key ] ) ) {
789 + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
790 + if ( empty( $valid_plan_ids ) ) {
791 + $valid_plan_ids = self::get_all_newsletter_plan_ids();
792 + }
793 + $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service( $user_id );
794 + $is_paid_subscriber = $paywall->visitor_can_view_content( $valid_plan_ids, Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS );
795 + self::$user_is_paid_subscriber_cache[ $cache_key ] = $is_paid_subscriber;
796 + }
797 + return self::$user_is_paid_subscriber_cache[ $cache_key ];
798 + }
799 +
800 + /**
801 + * Determines whether the current user has a pending subscription.
802 + *
803 + * @return bool Whether the user has a pending subscription
804 + */
805 + public static function user_is_pending_subscriber() {
806 + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
807 + $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
808 + return $subscription_service->is_current_user_pending_subscriber();
809 + }
810 +
811 + /**
492 812 * Determines whether the current user can view the post based on the newsletter access level
493 813 * and caches the result.
494 814 *
815 + * @param int|null $post_id Explicit post id to check against.
816 + *
495 817 * @return bool Whether the post can be viewed
496 818 */
497 - public static function user_can_view_post() {
819 + public static function user_can_view_post( $post_id = null ) {
498 820 $user_id = get_current_user_id();
499 - $post_id = get_the_ID();
821 + if ( null === $post_id ) {
822 + $post_id = get_the_ID();
823 + }
500 824
501 825 if ( false === $post_id ) {
502 826 $post_id = 0;
503 827 }
@@ -502,30 +826,42 @@
502 826 $post_id = 0;
503 827 }
504 828
505 829 $cache_key = sprintf( '%d_%d', $user_id, $post_id );
506 - if ( $user_id !== 0 && isset( self::$user_can_view_post_cache[ $cache_key ] ) ) {
830 + if ( isset( self::$user_can_view_post_cache[ $cache_key ] ) ) {
507 831 return self::$user_can_view_post_cache[ $cache_key ];
508 832 }
509 833
510 - $post_access_level = self::get_post_access_level();
511 - if ( Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
834 + $post_access_level = self::get_post_access_level( $post_id );
835 + if ( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
512 836 self::$user_can_view_post_cache[ $cache_key ] = true;
513 837 return true;
514 838 }
515 839
516 - if ( $user_id === 0 ) {
517 - if ( defined( 'WPCOM_SENDING_POST_TO_SUBSCRIBERS' ) && WPCOM_SENDING_POST_TO_SUBSCRIBERS ) {
518 - if ( Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS === $post_access_level ) {
519 - return true;
520 - }
521 - }
840 + // we are sending the post to subscribers so the user is a subscriber
841 + if ( defined( 'WPCOM_SENDING_POST_TO_SUBSCRIBERS' ) && WPCOM_SENDING_POST_TO_SUBSCRIBERS && Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS === $post_access_level ) {
842 + self::$user_can_view_post_cache[ $cache_key ] = true;
843 + return true;
522 844 }
523 845
524 846 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
525 - $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
526 - $can_view_post = $paywall->visitor_can_view_content( self::get_all_newsletter_plan_ids(), $post_access_level );
847 + $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
527 848
849 + $all_newsletters_plan_ids = self::get_all_newsletter_plan_ids();
850 +
851 + if ( 0 === count( $all_newsletters_plan_ids ) &&
852 + (
853 + Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS === $post_access_level ||
854 + Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS === $post_access_level
855 + )
856 + ) {
857 + // The post is paywalled but there is no newsletter plans on the site.
858 + // We downgrade the post level to subscribers-only
859 + $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS;
860 + }
861 +
862 + $can_view_post = $paywall->visitor_can_view_content( $all_newsletters_plan_ids, $post_access_level );
863 +
528 864 self::$user_can_view_post_cache[ $cache_key ] = $can_view_post;
529 865 return $can_view_post;
530 866 }
531 867
@@ -536,13 +872,31 @@
536 872 *
537 873 * @return bool
538 874 */
539 875 public static function is_enabled_jetpack_recurring_payments() {
540 - $api_available = ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Jetpack::is_connection_ready() );
876 + $api_available = ( new Host() )->is_wpcom_simple() || Jetpack::is_connection_ready();
541 877 return $api_available;
542 878 }
543 879
544 880 /**
881 + * Whether to enable the blocks in the editor.
882 + * All Monetize blocks (except Simple Payments) need a user with at least `edit_posts` capability
883 + *
884 + * @return bool
885 + */
886 + public static function should_enable_monetize_blocks_in_editor() {
887 + if ( ! is_admin() ) {
888 + // We enable the block for the front-end in all cases
889 + return true;
890 +
891 + }
892 +
893 + $is_offline_mode = ( new Status() )->is_offline_mode();
894 + $enable_monetize_blocks_in_editor = ( new Host() )->is_wpcom_simple() || ( ! $is_offline_mode );
895 + return $enable_monetize_blocks_in_editor;
896 + }
897 +
898 + /**
545 899 * Whether site has any paid plan.
546 900 *
547 901 * @param string $type - Type of a plan for which site is configured. For now supports empty and newsletter.
548 902 *
@@ -567,26 +921,90 @@
567 921 return ( is_countable( $plans ) && count( $plans ) > 0 );
568 922 }
569 923
570 924 /**
571 - * Return membership plans
925 + * Return the list of plan posts
572 926 *
927 + * @return WP_Post[]|WP_Error
928 + */
929 + public static function get_all_plans() {
930 + if ( ! self::is_enabled_jetpack_recurring_payments() ) {
931 + return array();
932 + }
933 +
934 + // We can retrieve the data directly except on a Jetpack/Atomic cached site or
935 + $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
936 + if ( ! $is_cached_site ) {
937 + return get_posts(
938 + array(
939 + 'posts_per_page' => -1,
940 + 'post_type' => self::$post_type_plan,
941 + )
942 + );
943 + } else {
944 + // On cached site on WPCOM
945 + require_lib( 'memberships' );
946 + return Memberships_Product::get_plans_posts_list( get_current_blog_id() );
947 + }
948 + }
949 +
950 + /**
951 + * Return all membership plans ids (deleted or not)
952 + * This function is used both on WPCOM or on Jetpack self-hosted.
953 + * Depending on the environment we need to mitigate where the data is retrieved from.
954 + *
955 + * @param bool $allow_deleted Whether to allow deleted plans to be returned. Defaults to true.
956 + *
573 957 * @return array
574 958 */
575 - public static function get_all_newsletter_plan_ids() {
959 + public static function get_all_newsletter_plan_ids( $allow_deleted = true ) {
960 +
576 961 if ( ! self::is_enabled_jetpack_recurring_payments() ) {
577 962 return array();
578 963 }
579 964
580 - return get_posts(
581 - array(
582 - 'posts_per_page' => -1,
583 - 'fields' => 'ids',
584 - 'meta_value' => true,
585 - 'post_type' => self::$post_type_plan,
586 - 'meta_key' => 'jetpack_memberships_site_subscriber',
587 - )
588 - );
965 + // We can retrieve the data directly except on a Jetpack/Atomic cached site or
966 + $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
967 + if ( ! $is_cached_site ) {
968 + $meta_query = array(
969 + array(
970 + 'key' => 'jetpack_memberships_type',
971 + 'value' => self::$type_tier,
972 + ),
973 + );
974 +
975 + if ( $allow_deleted === false ) {
976 + $meta_query[] = array(
977 + 'key' => 'jetpack_memberships_is_deleted',
978 + 'compare' => 'NOT EXISTS',
979 + );
980 + }
981 +
982 + return get_posts(
983 + array(
984 + 'posts_per_page' => -1,
985 + 'fields' => 'ids',
986 + 'post_type' => self::$post_type_plan,
987 + 'meta_query' => $meta_query,
988 + )
989 + );
990 +
991 + } else {
992 + // On cached site on WPCOM
993 + require_lib( 'memberships' );
994 + $list = Memberships_Product::get_product_list( get_current_blog_id(), self::$type_tier, null, $allow_deleted );
995 +
996 + if ( is_wp_error( $list ) ) {
997 + return array();
998 + }
999 +
1000 + return array_map(
1001 + function ( $product ) {
1002 + return $product['id'];
1003 + }, // Returning only post ids
1004 + $list
1005 + );
1006 + }
589 1007 }
590 1008
591 1009 /**
592 1010 * Register the Recurring Payments Gutenberg block
@@ -602,11 +1020,12 @@
602 1020 if ( self::is_enabled_jetpack_recurring_payments() ) {
603 1021 Blocks::jetpack_register_block(
604 1022 'jetpack/recurring-payments',
605 1023 array(
606 - 'render_callback' => array( $this, 'render_button' ),
607 - 'uses_context' => array( 'isPremiumContentChild' ),
608 - 'provides_context' => array(
1024 + 'render_callback' => array( $this, 'render_button' ),
1025 + 'render_email_callback' => array( $this, 'render_button_email' ),
1026 + 'uses_context' => array( 'isPremiumContentChild' ),
1027 + 'provides_context' => array(
609 1028 'jetpack/parentBlockWidth' => 'width',
610 1029 ),
611 1030 )
612 1031 );
@@ -611,9 +1030,9 @@
611 1030 )
612 1031 );
613 1032 } else {
614 1033 Jetpack_Gutenberg::set_extension_unavailable(
615 - 'jetpack/recurring-payments',
1034 + 'recurring-payments',
616 1035 'missing_plan',
617 1036 array(
618 1037 'required_feature' => 'memberships',
619 1038 'required_plan' => self::$required_plan,
@@ -633,16 +1052,76 @@
633 1052 */
634 1053 public static function get_join_others_text( $subscribers_total ) {
635 1054 if ( $subscribers_total >= 1000000 ) {
636 1055 /* translators: %s: number of folks following the blog, millions(M) with one decimal. i.e. 1.1 */
637 - return sprintf( __( 'Join %sM other subscribers', 'jetpack' ), number_format_i18n( $subscribers_total / 1000000, 1 ) );
1056 + return sprintf( __( 'Join %sM other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000000, 1 ) ) );
638 1057 }
639 1058 if ( $subscribers_total >= 10000 ) {
640 1059 /* translators: %s: number of folks following the blog, thousands(K) with one decimal. i.e. 1.1 */
641 - return sprintf( __( 'Join %sK other subscribers', 'jetpack' ), number_format_i18n( $subscribers_total / 1000, 1 ) );
1060 + return sprintf( __( 'Join %sK other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000, 1 ) ) );
642 1061 }
643 1062
644 1063 /* translators: %s: number of folks following the blog */
645 1064 return sprintf( _n( 'Join %s other subscriber', 'Join %s other subscribers', $subscribers_total, 'jetpack' ), number_format_i18n( $subscribers_total ) );
1065 + }
1066 +
1067 + /**
1068 + * Returns the email of the current user.
1069 + *
1070 + * @return string
1071 + */
1072 + public static function get_current_user_email() {
1073 + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1074 + $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1075 + return $subscription_service->get_subscriber_email();
1076 + }
1077 +
1078 + /**
1079 + * Returns if the current user is subscribed or not.
1080 + *
1081 + * @return boolean
1082 + */
1083 + public static function is_current_user_subscribed() {
1084 + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1085 + $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1086 + return $subscription_service->is_current_user_subscribed();
1087 + }
1088 +
1089 + /**
1090 + * Render a tier description (stored as markdown text) to safe HTML.
1091 + *
1092 + * Uses Jetpack's markdown parser, restores paragraph structure (the parser
1093 + * strips <p> tags expecting wpautop to run later), forces links to open in a
1094 + * new tab (descriptions are shown inside the subscribe modal's iframe), and
1095 + * finally sanitizes the output to a small tag allowlist.
1096 + *
1097 + * @param mixed $description Raw tier description (markdown text). Non-scalar
1098 + * values are treated as empty.
1099 + * @return string Sanitized HTML, or an empty string for an empty description.
1100 + */
1101 + public static function render_tier_description_html( $description ) {
1102 + if ( ! is_scalar( $description ) ) {
1103 + return '';
1104 + }
1105 + $description = (string) $description;
1106 + if ( '' === trim( $description ) ) {
1107 + return '';
1108 + }
1109 +
1110 + if ( ! class_exists( 'WPCom_Markdown' ) ) {
1111 + require_once JETPACK__PLUGIN_DIR . 'modules/markdown/easy-markdown.php';
1112 + }
1113 +
1114 + $html = WPCom_Markdown::get_instance()->transform(
1115 + $description,
1116 + array(
1117 + 'unslash' => false,
1118 + 'id' => false,
1119 + )
1120 + );
1121 + $html = wpautop( $html );
1122 + $html = links_add_target( $html, '_blank' );
1123 +
1124 + return wp_kses( $html, self::TIER_DESCRIPTION_ALLOWED_HTML );
646 1125 }
647 1126 }
648 1127 Jetpack_Memberships::get_instance();