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 +1166 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,1166 @@
1 +<?php
2 +/**
3 + * Jetpack_Memberships: wrapper for memberships functions.
4 + *
5 + * @package Jetpack
6 + * @since 7.3.0
7 + */
8 +
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;
16 +
17 +if ( ! defined( 'ABSPATH' ) ) {
18 + exit( 0 );
19 +}
20 +
21 +require_once __DIR__ . '/../../extensions/blocks/subscriptions/constants.php';
22 +
23 +/**
24 + * Class Jetpack_Memberships
25 + * This class represents the Memberships functionality.
26 + */
27 +class Jetpack_Memberships {
28 + /**
29 + * CSS class prefix to use in the styling.
30 + *
31 + * @var string
32 + */
33 + public static $css_classname_prefix = 'jetpack-memberships';
34 + /**
35 + * Our CPT type for the product (plan).
36 + *
37 + * @var string
38 + */
39 + public static $post_type_plan = 'jp_mem_plan';
40 +
41 + /**
42 + * Our CPT type for the product (plan).
43 + *
44 + * @var string
45 + */
46 + public static $post_type_coupon = 'memberships_coupon';
47 +
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 + /**
63 + * Post meta that will store the level of access for newsletters
64 + *
65 + * @var string
66 + */
67 + public static $post_access_level_meta_name = META_NAME_FOR_POST_LEVEL_ACCESS_SETTINGS;
68 +
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 + /**
77 + * Button block type to use.
78 + *
79 + * @var string
80 + */
81 + private static $button_block_name = 'recurring-payments';
82 +
83 + /**
84 + * These are defaults for wp_kses ran on the membership button.
85 + *
86 + * @var array
87 + */
88 + private static $tags_allowed_in_the_button = array( 'br' => array() );
89 +
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 + /**
116 + * The minimum required plan for this Gutenberg block.
117 + *
118 + * @var string Plan slug
119 + */
120 + private static $required_plan;
121 +
122 + /**
123 + * Track recurring payments block registration.
124 + *
125 + * @var boolean True if block registration has been executed.
126 + */
127 + private static $has_registered_block = false;
128 +
129 + /**
130 + * Classic singleton pattern
131 + *
132 + * @var Jetpack_Memberships
133 + */
134 + private static $instance;
135 +
136 + /**
137 + * Cached results of user_can_view_post() method.
138 + *
139 + * @var array
140 + */
141 + private static $user_can_view_post_cache = array();
142 +
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 + /**
165 + * Currencies we support and Stripe's minimum amount for a transaction in that currency.
166 + *
167 + * @link https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
168 + *
169 + * List has to be in with `SUPPORTED_CURRENCIES` in extensions/shared/currencies.js.
170 + */
171 + const SUPPORTED_CURRENCIES = array(
172 + 'USD' => 0.5,
173 + 'AUD' => 0.5,
174 + 'BRL' => 0.5,
175 + 'CAD' => 0.5,
176 + 'CHF' => 0.5,
177 + 'DKK' => 2.5,
178 + 'EUR' => 0.5,
179 + 'GBP' => 0.3,
180 + 'HKD' => 4.0,
181 + 'INR' => 0.5,
182 + 'JPY' => 50,
183 + 'MXN' => 10,
184 + 'NOK' => 3.0,
185 + 'NZD' => 0.5,
186 + 'PLN' => 2.0,
187 + 'SEK' => 3.0,
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,
198 + );
199 +
200 + /**
201 + * Jetpack_Memberships constructor.
202 + */
203 + private function __construct() {}
204 +
205 + /**
206 + * The actual constructor initializing the object.
207 + *
208 + * @return Jetpack_Memberships
209 + */
210 + public static function get_instance() {
211 + if ( ! self::$instance ) {
212 + self::$instance = new self();
213 + self::$instance->register_init_hook();
214 + // Yes, `pro-plan` with a dash, `jetpack_personal` with an underscore. Check the v1.5 endpoint to verify.
215 + $wpcom_plan_slug = defined( 'ENABLE_PRO_PLAN' ) ? 'pro-plan' : 'personal-bundle';
216 + self::$required_plan = ( new Host() )->is_wpcom_simple() ? $wpcom_plan_slug : 'jetpack_personal';
217 + }
218 +
219 + return self::$instance;
220 + }
221 + /**
222 + * Get the map that defines the shape of CPT post. keys are names of fields and
223 + * 'meta' is the name of actual WP post meta field that corresponds.
224 + *
225 + * @return array
226 + */
227 + private static function get_plan_property_mapping() {
228 + $meta_prefix = 'jetpack_memberships_';
229 + $properties = array(
230 + 'price' => array(
231 + 'meta' => $meta_prefix . 'price',
232 + ),
233 + 'currency' => array(
234 + 'meta' => $meta_prefix . 'currency',
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 + ),
251 + );
252 + return $properties;
253 + }
254 +
255 + /**
256 + * Inits further hooks on init hook.
257 + */
258 + private function register_init_hook() {
259 + add_action( 'init', array( $this, 'init_hook_action' ) );
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' ) );
263 + }
264 +
265 + /**
266 + * Actual hooks initializing on init.
267 + */
268 + public function init_hook_action() {
269 + add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
270 + add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
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 + }
276 + }
277 +
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 + /**
290 + * Sets up the custom post types for the module.
291 + */
292 + private function setup_cpts() {
293 + /*
294 + * PLAN data structure.
295 + */
296 + $capabilities = array(
297 + 'edit_post' => 'edit_posts',
298 + 'read_post' => 'read_private_posts',
299 + 'delete_post' => 'delete_posts',
300 + 'edit_posts' => 'edit_posts',
301 + 'edit_others_posts' => 'edit_others_posts',
302 + 'publish_posts' => 'publish_posts',
303 + 'read_private_posts' => 'read_private_posts',
304 + );
305 + $order_args = array(
306 + 'label' => esc_html__( 'Plan', 'jetpack' ),
307 + 'description' => esc_html__( 'Recurring Payments plans', 'jetpack' ),
308 + 'supports' => array( 'title', 'custom-fields', 'content' ),
309 + 'hierarchical' => false,
310 + 'public' => false,
311 + 'show_ui' => false,
312 + 'show_in_menu' => false,
313 + 'show_in_admin_bar' => false,
314 + 'show_in_nav_menus' => false,
315 + 'can_export' => true,
316 + 'has_archive' => false,
317 + 'exclude_from_search' => true,
318 + 'publicly_queryable' => false,
319 + 'rewrite' => false,
320 + 'capabilities' => $capabilities,
321 + 'show_in_rest' => false,
322 + );
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 );
343 + }
344 +
345 + /**
346 + * Allows custom post types to be used by REST API.
347 + *
348 + * @param array $post_types - other post types.
349 + *
350 + * @see hook 'rest_api_allowed_post_types'
351 + * @return array
352 + */
353 + public function allow_rest_api_types( $post_types ) {
354 + $post_types[] = self::$post_type_plan;
355 + $post_types[] = self::$post_type_coupon;
356 +
357 + return $post_types;
358 + }
359 +
360 + /**
361 + * Allows custom meta fields to sync.
362 + *
363 + * @param array $post_meta - previously changet post meta.
364 + *
365 + * @return array
366 + */
367 + public function allow_sync_post_meta( $post_meta ) {
368 + $meta_keys_plans = array_map(
369 + array( $this, 'return_meta' ),
370 + self::get_plan_property_mapping()
371 + );
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 + );
397 + }
398 +
399 + /**
400 + * This returns meta attribute of passet array.
401 + * Used for array functions.
402 + *
403 + * @param array $map - stuff.
404 + *
405 + * @return mixed
406 + */
407 + public function return_meta( $map ) {
408 + return $map['meta'];
409 + }
410 +
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 + /**
425 + * Renders a preview of the Recurring Payment button, which is not hooked
426 + * up to the subscription url. Used to preview the block on the frontend
427 + * for site editors when Stripe has not been connected.
428 + *
429 + * @param array $attrs - attributes in the shortcode.
430 + * @param string $content - Recurring Payment block content.
431 + *
432 + * @return string|void
433 + */
434 + public function render_button_preview( $attrs, $content = null ) {
435 + if ( ! empty( $content ) ) {
436 + $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
437 + $content = str_replace( 'recurring-payments-id', $block_id, $content );
438 + $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
439 + return $content;
440 + }
441 + return $this->deprecated_render_button_v1( $attrs, null );
442 + }
443 +
444 + /**
445 + * Determines whether the button preview should be rendered. Returns true
446 + * if the user has editing permissions, the button is not configured correctly
447 + * (because it requires a plan upgrade or Stripe connection), and the
448 + * button is a child of a Premium Content block.
449 + *
450 + * @param WP_Block $block Recurring Payments block instance.
451 + *
452 + * @return boolean
453 + */
454 + public function should_render_button_preview( $block ) {
455 + $user_can_edit = static::user_can_edit();
456 + $requires_stripe_connection = ! static::has_connected_account();
457 +
458 + $jetpack_ready = ! self::is_enabled_jetpack_recurring_payments();
459 +
460 + $is_premium_content_child = false;
461 + if ( isset( $block ) && isset( $block->context['isPremiumContentChild'] ) ) {
462 + $is_premium_content_child = (int) $block->context['isPremiumContentChild'];
463 + }
464 +
465 + return $is_premium_content_child &&
466 + $user_can_edit &&
467 + $requires_stripe_connection &&
468 + $jetpack_ready;
469 + }
470 +
471 + /**
472 + * Callback that parses the membership purchase shortcode.
473 + *
474 + * @param array $attributes - attributes in the shortcode. `id` here is the CPT id of the plan.
475 + * @param string $content - Recurring Payment block content.
476 + * @param WP_Block $block - Recurring Payment block instance.
477 + *
478 + * @return string|void - HTML for the button, void removes the button.
479 + */
480 + public function render_button( $attributes, $content = null, $block = null ) {
481 + Jetpack_Gutenberg::load_assets_as_required( self::$button_block_name );
482 +
483 + if ( $this->should_render_button_preview( $block ) ) {
484 + return $this->render_button_preview( $attributes, $content );
485 + }
486 +
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' ) ) );
489 + }
490 +
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'] );
498 + }
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 ) ) {
524 + return;
525 + }
526 + $plan_id = implode( '+', $valid_plans );
527 +
528 + if ( ! empty( $content ) ) {
529 + $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
530 + $content = str_replace( 'recurring-payments-id', $block_id, $content );
531 + $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
532 + $subscribe_url = $this->get_subscription_url( $plan_id );
533 +
534 + $content = preg_replace( '/(href=".*")/U', 'href="' . $subscribe_url . '"', $content );
535 + $content = wp_kses_post( $content );
536 +
537 + return $content;
538 + }
539 +
540 + return $this->deprecated_render_button_v1( $attributes, $plan_id );
541 + }
542 +
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 + /**
581 + * Builds subscription URL for this membership using the current blog and
582 + * supplied plan IDs.
583 + *
584 + * @param integer $plan_id - Unique ID for the plan being subscribed to.
585 + * @return string
586 + */
587 + public function get_subscription_url( $plan_id ) {
588 + global $wp;
589 +
590 + return add_query_arg(
591 + array(
592 + 'blog' => esc_attr( self::get_blog_id() ),
593 + 'plan' => esc_attr( $plan_id ),
594 + 'lang' => esc_attr( get_locale() ),
595 + 'pid' => esc_attr( get_the_ID() ), // Needed for analytics purposes.
596 + 'redirect' => esc_attr( rawurlencode( home_url( $wp->request ) ) ), // Needed for redirect back in case of redirect-based flow.
597 + ),
598 + 'https://subscribe.wordpress.com/memberships/'
599 + );
600 + }
601 +
602 + /**
603 + * Renders a deprecated legacy version of the button HTML.
604 + *
605 + * @param array $attrs - Array containing the Recurring Payment block attributes.
606 + * @param integer $plan_id - Unique plan ID the membership is for.
607 + *
608 + * @return string
609 + */
610 + public function deprecated_render_button_v1( $attrs, $plan_id ) {
611 + $button_label = $attrs['submitButtonText'] ?? __( 'Your contribution', 'jetpack' );
612 +
613 + $button_styles = array();
614 + if ( ! empty( $attrs['customBackgroundButtonColor'] ) ) {
615 + array_push(
616 + $button_styles,
617 + sprintf(
618 + 'background-color: %s',
619 + sanitize_hex_color( $attrs['customBackgroundButtonColor'] )
620 + )
621 + );
622 + }
623 + if ( ! empty( $attrs['customTextButtonColor'] ) ) {
624 + array_push(
625 + $button_styles,
626 + sprintf(
627 + 'color: %s',
628 + sanitize_hex_color( $attrs['customTextButtonColor'] )
629 + )
630 + );
631 + }
632 + $button_styles = implode( ';', $button_styles );
633 +
634 + return sprintf(
635 + '<div class="%1$s"><a role="button" href="%2$s" class="%3$s" style="%4$s">%5$s</a></div>',
636 + esc_attr(
637 + Blocks::classes(
638 + self::$button_block_name,
639 + $attrs,
640 + array( 'wp-block-button' )
641 + )
642 + ),
643 + esc_url( $this->get_subscription_url( $plan_id ) ),
644 + isset( $attrs['submitButtonClasses'] ) ? esc_attr( $attrs['submitButtonClasses'] ) : 'wp-block-button__link',
645 + esc_attr( $button_styles ),
646 + wp_kses( $button_label, self::$tags_allowed_in_the_button )
647 + );
648 + }
649 +
650 + /**
651 + * Get current blog id.
652 + *
653 + * @return int
654 + */
655 + public static function get_blog_id() {
656 + if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
657 + return get_current_blog_id();
658 + }
659 +
660 + return Jetpack_Options::get_option( 'id' );
661 + }
662 +
663 + /**
664 + * Get the id of the connected payment acount (Stripe etc).
665 + *
666 + * @return bool
667 + */
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;
677 + }
678 +
679 + /**
680 + * Get the post access level
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 + *
686 + * @return string the actual post access level (see projects/plugins/jetpack/extensions/blocks/subscriptions/constants.js for the values).
687 + */
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;
726 + }
727 +
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 + /**
755 + * Determines whether the current user can edit.
756 + *
757 + * @return bool Whether the user can edit.
758 + */
759 + public static function user_can_edit() {
760 + $user = wp_get_current_user();
761 + return 0 !== $user->ID && current_user_can( 'edit_post', get_the_ID() );
762 + }
763 +
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 + /**
821 + * Determines whether the current user can view the post based on the newsletter access level
822 + * and caches the result.
823 + *
824 + * @param int|null $post_id Explicit post id to check against.
825 + *
826 + * @return bool Whether the post can be viewed
827 + */
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 ) {
852 + $user_id = get_current_user_id();
853 + if ( null === $post_id ) {
854 + $post_id = get_the_ID();
855 + }
856 +
857 + if ( false === $post_id ) {
858 + $post_id = 0;
859 + }
860 +
861 + $cache_key = sprintf( '%d_%d_%d', $user_id, $post_id, (int) $allow_editor_access );
862 + if ( isset( self::$user_can_view_post_cache[ $cache_key ] ) ) {
863 + return self::$user_can_view_post_cache[ $cache_key ];
864 + }
865 +
866 + $post_access_level = self::get_post_access_level( $post_id );
867 + if ( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
868 + self::$user_can_view_post_cache[ $cache_key ] = true;
869 + return true;
870 + }
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 +
878 + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
879 + $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
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 +
903 + self::$user_can_view_post_cache[ $cache_key ] = $can_view_post;
904 + return $can_view_post;
905 + }
906 +
907 + /**
908 + * Whether Recurring Payments are enabled. True if the block
909 + * is supported by the site's plan, or if it is a Jetpack site
910 + * and the feature to enable upgrade nudges is active.
911 + *
912 + * @return bool
913 + */
914 + public static function is_enabled_jetpack_recurring_payments() {
915 + $api_available = ( new Host() )->is_wpcom_simple() || Jetpack::is_connection_ready();
916 + return $api_available;
917 + }
918 +
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 + /**
938 + * Whether site has any paid plan.
939 + *
940 + * @param string $type - Type of a plan for which site is configured. For now supports empty and newsletter.
941 + *
942 + * @return bool
943 + */
944 + public static function has_configured_plans_jetpack_recurring_payments( $type = '' ) {
945 + if ( ! self::is_enabled_jetpack_recurring_payments() ) {
946 + return false;
947 + }
948 + $query = array(
949 + 'post_type' => self::$post_type_plan,
950 + 'posts_per_page' => 1,
951 + );
952 +
953 + // We want to see if user has any plan marked as a newsletter set up.
954 + if ( 'newsletter' === $type ) {
955 + $query['meta_key'] = 'jetpack_memberships_site_subscriber';
956 + $query['meta_value'] = true;
957 + }
958 +
959 + $plans = get_posts( $query );
960 + return ( is_countable( $plans ) && count( $plans ) > 0 );
961 + }
962 +
963 + /**
964 + * Return the list of plan posts
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 + *
996 + * @return array
997 + */
998 + public static function get_all_newsletter_plan_ids( $allow_deleted = true ) {
999 +
1000 + if ( ! self::is_enabled_jetpack_recurring_payments() ) {
1001 + return array();
1002 + }
1003 +
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 + }
1046 + }
1047 +
1048 + /**
1049 + * Register the Recurring Payments Gutenberg block
1050 + */
1051 + public function register_gutenberg_block() {
1052 + // This gate was introduced to prevent duplicate registration. A race condition exists where
1053 + // the registration that happens via extensions/blocks/recurring-payments/recurring-payments.php
1054 + // was adding the registration action after the action had been run in some contexts.
1055 + if ( self::$has_registered_block ) {
1056 + return;
1057 + }
1058 +
1059 + if ( self::is_enabled_jetpack_recurring_payments() ) {
1060 + Blocks::jetpack_register_block(
1061 + 'jetpack/recurring-payments',
1062 + 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(
1067 + 'jetpack/parentBlockWidth' => 'width',
1068 + ),
1069 + )
1070 + );
1071 + } else {
1072 + Jetpack_Gutenberg::set_extension_unavailable(
1073 + 'recurring-payments',
1074 + 'missing_plan',
1075 + array(
1076 + 'required_feature' => 'memberships',
1077 + 'required_plan' => self::$required_plan,
1078 + )
1079 + );
1080 + }
1081 +
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 );
1164 + }
1165 +}
1166 +Jetpack_Memberships::get_instance();