Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
40.69% covered (danger)
40.69%
190 / 467
20.51% covered (danger)
20.51%
8 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Memberships
41.04% covered (danger)
41.04%
190 / 463
20.51% covered (danger)
20.51%
8 / 39
4216.52
0.00% covered (danger)
0.00%
0 / 1
 clear_post_access_level_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_instance
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 get_plan_property_mapping
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
2
 register_init_hook
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 init_hook_action
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 subscriber_logout
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setup_cpts
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
2
 allow_rest_api_types
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 allow_sync_post_meta
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
2
 return_meta
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 render_button_error
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 render_button_preview
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 should_render_button_preview
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 render_button
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
210
 render_button_email
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
9.08
 get_subscription_url
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 deprecated_render_button_v1
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 get_blog_id
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 has_connected_account
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 get_post_access_level
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
8.02
 get_post_tier
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 user_can_edit
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 clear_cache
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 user_is_paid_subscriber
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
5.25
 user_is_pending_subscriber
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 user_can_view_post
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 user_has_subscription_access
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 check_post_access
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
15.22
 is_enabled_jetpack_recurring_payments
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 should_enable_monetize_blocks_in_editor
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 has_configured_plans_jetpack_recurring_payments
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 get_all_plans
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
4.25
 get_all_newsletter_plan_ids
69.70% covered (warning)
69.70%
23 / 33
0.00% covered (danger)
0.00%
0 / 1
7.00
 register_gutenberg_block
65.22% covered (warning)
65.22%
15 / 23
0.00% covered (danger)
0.00%
0 / 1
3.38
 get_join_others_text
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
9
 get_current_user_email
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 is_current_user_subscribed
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 render_tier_description_html
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
4.00
1<?php
2/**
3 * Jetpack_Memberships: wrapper for memberships functions.
4 *
5 * @package    Jetpack
6 * @since      7.3.0
7 */
8
9use Automattic\Jetpack\Blocks;
10use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service;
11use Automattic\Jetpack\Status;
12use Automattic\Jetpack\Status\Host;
13use Automattic\Jetpack\Status\Request;
14use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_LEVEL_ACCESS_SETTINGS;
15use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_TIER_ID_SETTINGS;
16
17if ( ! defined( 'ABSPATH' ) ) {
18    exit( 0 );
19}
20
21require_once __DIR__ . '/../../extensions/blocks/subscriptions/constants.php';
22
23/**
24 * Class Jetpack_Memberships
25 * This class represents the Memberships functionality.
26 */
27class 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        $prefix = $user_id . '_';
777        foreach ( array_keys( self::$user_is_paid_subscriber_cache ) as $cache_key ) {
778            if ( str_starts_with( $cache_key, $prefix ) ) {
779                unset( self::$user_is_paid_subscriber_cache[ $cache_key ] );
780            }
781        }
782        foreach ( array_keys( self::$user_can_view_post_cache ) as $cache_key ) {
783            if ( str_starts_with( $cache_key, $prefix ) ) {
784                unset( self::$user_can_view_post_cache[ $cache_key ] );
785            }
786        }
787    }
788
789    /**
790     * Determines whether the current user is a paid subscriber and caches the result.
791     *
792     * @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.
793     * @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).
794     * @return bool Whether the post can be viewed
795     */
796    public static function user_is_paid_subscriber( $valid_plan_ids = array(), $user_id = null ) {
797        if ( empty( $user_id ) ) {
798            $user_id = get_current_user_id();
799            if ( empty( $user_id ) ) {
800                return false;
801            }
802        }
803        // sort and stringify sorted valid plan ids to use as a cache key
804        sort( $valid_plan_ids );
805        $cache_key = $user_id . '_' . implode( ',', $valid_plan_ids );
806        if ( ! isset( self::$user_is_paid_subscriber_cache[ $cache_key ] ) ) {
807            require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
808            if ( empty( $valid_plan_ids ) ) {
809                $valid_plan_ids = self::get_all_newsletter_plan_ids();
810            }
811            $paywall            = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service( $user_id );
812            $is_paid_subscriber = $paywall->visitor_can_view_content( $valid_plan_ids, Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS );
813            self::$user_is_paid_subscriber_cache[ $cache_key ] = $is_paid_subscriber;
814        }
815        return self::$user_is_paid_subscriber_cache[ $cache_key ];
816    }
817
818    /**
819     * Determines whether the current user has a pending subscription.
820     *
821     * @return bool Whether the user has a pending subscription
822     */
823    public static function user_is_pending_subscriber() {
824        require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
825        $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
826        return $subscription_service->is_current_user_pending_subscriber();
827    }
828
829    /**
830     * Determines whether the current user can view the post based on the newsletter access level
831     * and caches the result.
832     *
833     * @param int|null $post_id Explicit post id to check against.
834     *
835     * @return bool Whether the post can be viewed
836     */
837    public static function user_can_view_post( $post_id = null ) {
838        return self::check_post_access( $post_id, true );
839    }
840
841    /**
842     * Check the post's subscription requirement without granting access for editing it.
843     *
844     * @since 16.4
845     *
846     * @param int|null $post_id Explicit post ID, or the loop post when omitted.
847     * @return bool Whether the visitor meets the post's subscription requirement.
848     */
849    public static function user_has_subscription_access( $post_id = null ) {
850        return self::check_post_access( $post_id, false );
851    }
852
853    /**
854     * Evaluate and cache post access with or without the editorial exception.
855     *
856     * @param int|null $post_id             Post to check.
857     * @param bool     $allow_editor_access Whether editing the post can grant access.
858     * @return bool Whether access is granted.
859     */
860    private static function check_post_access( $post_id, $allow_editor_access ) {
861        $user_id = get_current_user_id();
862        if ( null === $post_id ) {
863            $post_id = get_the_ID();
864        }
865
866        if ( false === $post_id ) {
867            $post_id = 0;
868        }
869
870        require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
871
872        // Answered before the cache: it is keyed by user, and the same request still renders for that user afterwards.
873        if ( \Automattic\Jetpack\Extensions\Premium_Content\is_rendering_for_activitypub() ) {
874            return Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === self::get_post_access_level( $post_id );
875        }
876
877        $cache_key = sprintf( '%d_%d_%d', $user_id, $post_id, (int) $allow_editor_access );
878        if ( isset( self::$user_can_view_post_cache[ $cache_key ] ) ) {
879            return self::$user_can_view_post_cache[ $cache_key ];
880        }
881
882        $post_access_level = self::get_post_access_level( $post_id );
883        if ( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
884            self::$user_can_view_post_cache[ $cache_key ] = true;
885            return true;
886        }
887
888        // we are sending the post to subscribers so the user is a subscriber
889        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 ) {
890            self::$user_can_view_post_cache[ $cache_key ] = true;
891            return true;
892        }
893
894        $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
895
896        $all_newsletters_plan_ids = self::get_all_newsletter_plan_ids();
897
898        if ( 0 === count( $all_newsletters_plan_ids ) &&
899            (
900                Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS === $post_access_level ||
901                Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS === $post_access_level
902            )
903        ) {
904            // The post is paywalled but there is no newsletter plans on the site.
905            // We downgrade the post level to subscribers-only
906            $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS;
907        }
908
909        // Pass the post explicitly: callers outside the loop have no get_the_ID() to fall back on.
910        if ( $allow_editor_access ) {
911            // @phan-suppress-next-line PhanParamTooMany -- Concrete services accept the optional $post_id; interface omits it on purpose.
912            $can_view_post = $paywall->visitor_can_view_content( $all_newsletters_plan_ids, $post_access_level, $post_id );
913        } else {
914            $can_view_post = is_callable( array( $paywall, 'visitor_has_subscription_access' ) )
915                && $paywall->visitor_has_subscription_access( $all_newsletters_plan_ids, $post_access_level, $post_id );
916        }
917
918        self::$user_can_view_post_cache[ $cache_key ] = $can_view_post;
919        return $can_view_post;
920    }
921
922    /**
923     * Whether Recurring Payments are enabled. True if the block
924     * is supported by the site's plan, or if it is a Jetpack site
925     * and the feature to enable upgrade nudges is active.
926     *
927     * @return bool
928     */
929    public static function is_enabled_jetpack_recurring_payments() {
930        $api_available = ( new Host() )->is_wpcom_simple() || Jetpack::is_connection_ready();
931        return $api_available;
932    }
933
934    /**
935     * Whether to enable the blocks in the editor.
936     * All Monetize blocks (except Simple Payments) need a user with at least `edit_posts` capability
937     *
938     * @return bool
939     */
940    public static function should_enable_monetize_blocks_in_editor() {
941        if ( ! is_admin() ) {
942            // We enable the block for the front-end in all cases
943            return true;
944
945        }
946
947        $is_offline_mode                  = ( new Status() )->is_offline_mode();
948        $enable_monetize_blocks_in_editor = ( new Host() )->is_wpcom_simple() || ( ! $is_offline_mode );
949        return $enable_monetize_blocks_in_editor;
950    }
951
952    /**
953     * Whether site has any paid plan.
954     *
955     * @param string $type - Type of a plan for which site is configured. For now supports empty and newsletter.
956     *
957     * @return bool
958     */
959    public static function has_configured_plans_jetpack_recurring_payments( $type = '' ) {
960        if ( ! self::is_enabled_jetpack_recurring_payments() ) {
961            return false;
962        }
963        $query = array(
964            'post_type'      => self::$post_type_plan,
965            'posts_per_page' => 1,
966        );
967
968        // Newsletter tiers or legacy mailing list plans.
969        if ( 'newsletter' === $type ) {
970            $query['meta_query'] = array(
971                // @phan-suppress-next-line PhanPluginMixedKeyNoKey
972                'relation' => 'OR',
973                array(
974                    'key'   => 'jetpack_memberships_type',
975                    'value' => self::$type_tier,
976                ),
977                array(
978                    'key'   => 'jetpack_memberships_site_subscriber',
979                    'value' => '1',
980                ),
981            );
982        }
983
984        $plans = get_posts( $query );
985        return ( is_countable( $plans ) && count( $plans ) > 0 );
986    }
987
988    /**
989     * Return the list of plan posts
990     *
991     * @return WP_Post[]|WP_Error
992     */
993    public static function get_all_plans() {
994        if ( ! self::is_enabled_jetpack_recurring_payments() ) {
995            return array();
996        }
997
998        // We can retrieve the data directly except on a Jetpack/Atomic cached site or
999        $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
1000        if ( ! $is_cached_site ) {
1001            return get_posts(
1002                array(
1003                    'posts_per_page' => -1,
1004                    'post_type'      => self::$post_type_plan,
1005                )
1006            );
1007        } else {
1008            // On cached site on WPCOM
1009            require_lib( 'memberships' );
1010            return Memberships_Product::get_plans_posts_list( get_current_blog_id() );
1011        }
1012    }
1013
1014    /**
1015     * Return all membership plans ids (deleted or not)
1016     * This function is used both on WPCOM or on Jetpack self-hosted.
1017     * Depending on the environment we need to mitigate where the data is retrieved from.
1018     *
1019     * @param bool $allow_deleted Whether to allow deleted plans to be returned. Defaults to true.
1020     *
1021     * @return array
1022     */
1023    public static function get_all_newsletter_plan_ids( $allow_deleted = true ) {
1024
1025        if ( ! self::is_enabled_jetpack_recurring_payments() ) {
1026            return array();
1027        }
1028
1029        // We can retrieve the data directly except on a Jetpack/Atomic cached site or
1030        $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
1031        if ( ! $is_cached_site ) {
1032            $meta_query = array(
1033                array(
1034                    'key'   => 'jetpack_memberships_type',
1035                    'value' => self::$type_tier,
1036                ),
1037            );
1038
1039            if ( $allow_deleted === false ) {
1040                $meta_query[] = array(
1041                    'key'     => 'jetpack_memberships_is_deleted',
1042                    'compare' => 'NOT EXISTS',
1043                );
1044            }
1045
1046            return get_posts(
1047                array(
1048                    'posts_per_page' => -1,
1049                    'fields'         => 'ids',
1050                    'post_type'      => self::$post_type_plan,
1051                    'meta_query'     => $meta_query,
1052                )
1053            );
1054
1055        } else {
1056            // On cached site on WPCOM
1057            require_lib( 'memberships' );
1058            $list = Memberships_Product::get_product_list( get_current_blog_id(), self::$type_tier, null, $allow_deleted );
1059
1060            if ( is_wp_error( $list ) ) {
1061                return array();
1062            }
1063
1064            return array_map(
1065                function ( $product ) {
1066                    return $product['id'];
1067                }, // Returning only post ids
1068                $list
1069            );
1070        }
1071    }
1072
1073    /**
1074     * Register the Recurring Payments Gutenberg block
1075     */
1076    public function register_gutenberg_block() {
1077        // This gate was introduced to prevent duplicate registration. A race condition exists where
1078        // the registration that happens via extensions/blocks/recurring-payments/recurring-payments.php
1079        // was adding the registration action after the action had been run in some contexts.
1080        if ( self::$has_registered_block ) {
1081            return;
1082        }
1083
1084        if ( self::is_enabled_jetpack_recurring_payments() ) {
1085            Blocks::jetpack_register_block(
1086                'jetpack/recurring-payments',
1087                array(
1088                    'render_callback'       => array( $this, 'render_button' ),
1089                    'render_email_callback' => array( $this, 'render_button_email' ),
1090                    'uses_context'          => array( 'isPremiumContentChild' ),
1091                    'provides_context'      => array(
1092                        'jetpack/parentBlockWidth' => 'width',
1093                    ),
1094                )
1095            );
1096        } else {
1097            Jetpack_Gutenberg::set_extension_unavailable(
1098                'recurring-payments',
1099                'missing_plan',
1100                array(
1101                    'required_feature' => 'memberships',
1102                    'required_plan'    => self::$required_plan,
1103                )
1104            );
1105        }
1106
1107        self::$has_registered_block = true;
1108    }
1109
1110    /**
1111     * Transforms a number into a human-readable join text string.
1112     *
1113     * Supports short form (e.g. 45.8K), longer rounded format (e.g. 45,800), and raw count (e.g. 45,806)
1114     * via the `jetpack_memberships_subscriber_count_format` filter.
1115     *
1116     * @param int $subscribers_total Total number of subscribers.
1117     *
1118     * @return string Human-readable version of the number in context.
1119     */
1120    public static function get_join_others_text( $subscribers_total ) {
1121        $subscribers_total = absint( $subscribers_total );
1122
1123        /**
1124         * Filter the format used for the subscriber count in the join others text.
1125         *
1126         * @since 16.4
1127         *
1128         * @param string $format            Subscriber count format: 'short', 'rounded', or 'raw'. Default 'short'.
1129         * @param int    $subscribers_total Total number of subscribers.
1130         */
1131        $format = apply_filters( 'jetpack_memberships_subscriber_count_format', 'short', $subscribers_total );
1132
1133        switch ( strtolower( (string) $format ) ) {
1134            case 'raw':
1135                /* translators: %s: number of folks following the blog */
1136                $text = sprintf( _n( 'Join %s other subscriber', 'Join %s other subscribers', $subscribers_total, 'jetpack' ), number_format_i18n( $subscribers_total ) );
1137                break;
1138
1139            case 'rounded':
1140                if ( $subscribers_total >= 1000000 ) {
1141                    $rounded_total = (int) round( round( $subscribers_total / 1000000, 1 ) * 1000000 );
1142                } elseif ( $subscribers_total >= 10000 ) {
1143                    $rounded_total = (int) round( round( $subscribers_total / 1000, 1 ) * 1000 );
1144                } else {
1145                    $rounded_total = $subscribers_total;
1146                }
1147
1148                /* translators: %s: number of folks following the blog */
1149                $text = sprintf( _n( 'Join %s other subscriber', 'Join %s other subscribers', $rounded_total, 'jetpack' ), number_format_i18n( $rounded_total ) );
1150                break;
1151
1152            case 'short':
1153            default:
1154                if ( $subscribers_total >= 1000000 ) {
1155                    /* translators: %s: number of folks following the blog, millions(M) with one decimal. i.e. 1.1 */
1156                    $text = sprintf( __( 'Join %sM other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000000, 1 ) ) );
1157                } elseif ( $subscribers_total >= 10000 ) {
1158                    /* translators: %s: number of folks following the blog, thousands(K) with one decimal. i.e. 1.1 */
1159                    $text = sprintf( __( 'Join %sK other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000, 1 ) ) );
1160                } else {
1161                    /* translators: %s: number of folks following the blog */
1162                    $text = sprintf( _n( 'Join %s other subscriber', 'Join %s other subscribers', $subscribers_total, 'jetpack' ), number_format_i18n( $subscribers_total ) );
1163                }
1164                break;
1165        }
1166
1167        return $text;
1168    }
1169
1170    /**
1171     * Returns the email of the current user.
1172     *
1173     * @return string
1174     */
1175    public static function get_current_user_email() {
1176        require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1177        $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1178        return $subscription_service->get_subscriber_email();
1179    }
1180
1181    /**
1182     * Returns if the current user is subscribed or not.
1183     *
1184     * @return boolean
1185     */
1186    public static function is_current_user_subscribed() {
1187        require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1188        $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1189        return $subscription_service->is_current_user_subscribed();
1190    }
1191
1192    /**
1193     * Render a tier description (stored as markdown text) to safe HTML.
1194     *
1195     * Uses Jetpack's markdown parser, restores paragraph structure (the parser
1196     * strips <p> tags expecting wpautop to run later), forces links to open in a
1197     * new tab (descriptions are shown inside the subscribe modal's iframe), and
1198     * finally sanitizes the output to a small tag allowlist.
1199     *
1200     * @param mixed $description Raw tier description (markdown text). Non-scalar
1201     *                          values are treated as empty.
1202     * @return string Sanitized HTML, or an empty string for an empty description.
1203     */
1204    public static function render_tier_description_html( $description ) {
1205        if ( ! is_scalar( $description ) ) {
1206            return '';
1207        }
1208        $description = (string) $description;
1209        if ( '' === trim( $description ) ) {
1210            return '';
1211        }
1212
1213        if ( ! class_exists( 'WPCom_Markdown' ) ) {
1214            require_once JETPACK__PLUGIN_DIR . 'modules/markdown/easy-markdown.php';
1215        }
1216
1217        $html = WPCom_Markdown::get_instance()->transform(
1218            $description,
1219            array(
1220                'unslash' => false,
1221                'id'      => false,
1222            )
1223        );
1224        $html = wpautop( $html );
1225        $html = links_add_target( $html, '_blank' );
1226
1227        return wp_kses( $html, self::TIER_DESCRIPTION_ALLOWED_HTML );
1228    }
1229}
1230Jetpack_Memberships::get_instance();