Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.65% covered (danger)
21.65%
89 / 411
5.56% covered (danger)
5.56%
2 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Memberships
21.87% covered (danger)
21.87%
89 / 407
5.56% covered (danger)
5.56%
2 / 36
6873.48
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 / 35
0.00% covered (danger)
0.00%
0 / 1
30
 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
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 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
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 user_is_paid_subscriber
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 user_is_pending_subscriber
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 user_can_view_post
80.00% covered (warning)
80.00%
20 / 25
0.00% covered (danger)
0.00%
0 / 1
11.97
 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
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 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
57.58% covered (warning)
57.58%
19 / 33
0.00% covered (danger)
0.00%
0 / 1
8.75
 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
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 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
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
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     * The minimum required plan for this Gutenberg block.
92     *
93     * @var string Plan slug
94     */
95    private static $required_plan;
96
97    /**
98     * Track recurring payments block registration.
99     *
100     * @var boolean True if block registration has been executed.
101     */
102    private static $has_registered_block = false;
103
104    /**
105     * Classic singleton pattern
106     *
107     * @var Jetpack_Memberships
108     */
109    private static $instance;
110
111    /**
112     * Cached results of user_can_view_post() method.
113     *
114     * @var array
115     */
116    private static $user_can_view_post_cache = array();
117
118    /**
119     * Cached results of user_is_paid_subscriber() method.
120     *
121     * @var array
122     */
123    private static $user_is_paid_subscriber_cache = array();
124
125    /**
126     * Cached results of get_post_access_level method.
127     *
128     * @var array
129     */
130    private static $post_access_level_cache = array();
131
132    /**
133     * Clear cached results of get_post_access_level method.
134     */
135    public static function clear_post_access_level_cache() {
136        self::$post_access_level_cache = array();
137    }
138
139    /**
140     * Currencies we support and Stripe's minimum amount for a transaction in that currency.
141     *
142     * @link https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
143     *
144     * List has to be in with `SUPPORTED_CURRENCIES` in extensions/shared/currencies.js.
145     */
146    const SUPPORTED_CURRENCIES = array(
147        'USD' => 0.5,
148        'AUD' => 0.5,
149        'BRL' => 0.5,
150        'CAD' => 0.5,
151        'CHF' => 0.5,
152        'DKK' => 2.5,
153        'EUR' => 0.5,
154        'GBP' => 0.3,
155        'HKD' => 4.0,
156        'INR' => 0.5,
157        'JPY' => 50,
158        'MXN' => 10,
159        'NOK' => 3.0,
160        'NZD' => 0.5,
161        'PLN' => 2.0,
162        'SEK' => 3.0,
163        'SGD' => 0.5,
164        'CZK' => 15.0,
165        'HUF' => 175.0,
166        'TWD' => 10.0,
167        'IDR' => 0,
168        'ILS' => 0,
169        'PHP' => 0,
170        'RUB' => 0,
171        'TRY' => 0,
172    );
173
174    /**
175     * Jetpack_Memberships constructor.
176     */
177    private function __construct() {}
178
179    /**
180     * The actual constructor initializing the object.
181     *
182     * @return Jetpack_Memberships
183     */
184    public static function get_instance() {
185        if ( ! self::$instance ) {
186            self::$instance = new self();
187            self::$instance->register_init_hook();
188            // Yes, `pro-plan` with a dash, `jetpack_personal` with an underscore. Check the v1.5 endpoint to verify.
189            $wpcom_plan_slug     = defined( 'ENABLE_PRO_PLAN' ) ? 'pro-plan' : 'personal-bundle';
190            self::$required_plan = ( new Host() )->is_wpcom_simple() ? $wpcom_plan_slug : 'jetpack_personal';
191        }
192
193        return self::$instance;
194    }
195    /**
196     * Get the map that defines the shape of CPT post. keys are names of fields and
197     * 'meta' is the name of actual WP post meta field that corresponds.
198     *
199     * @return array
200     */
201    private static function get_plan_property_mapping() {
202        $meta_prefix = 'jetpack_memberships_';
203        $properties  = array(
204            'price'           => array(
205                'meta' => $meta_prefix . 'price',
206            ),
207            'currency'        => array(
208                'meta' => $meta_prefix . 'currency',
209            ),
210            'site_subscriber' => array(
211                'meta' => $meta_prefix . 'site_subscriber',
212            ),
213            'product_id'      => array(
214                'meta' => $meta_prefix . 'product_id',
215            ),
216            'tier'            => array(
217                'meta' => $meta_prefix . 'tier',
218            ),
219            'is_deleted'      => array(
220                'meta' => $meta_prefix . 'is_deleted',
221            ),
222            'is_sandboxed'    => array(
223                'meta' => $meta_prefix . 'is_sandboxed',
224            ),
225        );
226        return $properties;
227    }
228
229    /**
230     * Inits further hooks on init hook.
231     */
232    private function register_init_hook() {
233        add_action( 'init', array( $this, 'init_hook_action' ) );
234        add_action( 'jetpack_register_gutenberg_extensions', array( $this, 'register_gutenberg_block' ) );
235        // phpcs:ignore WPCUT.SwitchBlog.SwitchBlog -- wpcom flags **every** use of switch_blog, apparently expecting valid instances to ignore or suppress the sniff.
236        add_action( 'switch_blog', array( $this, 'clear_post_access_level_cache' ) );
237    }
238
239    /**
240     * Actual hooks initializing on init.
241     */
242    public function init_hook_action() {
243        add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
244        add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
245        $this->setup_cpts();
246
247        if ( Jetpack::is_module_active( 'subscriptions' ) && Request::is_frontend() ) {
248            add_action( 'wp_logout', array( $this, 'subscriber_logout' ) );
249        }
250    }
251
252    /**
253     * Logs the subscriber out by clearing out the premium content cookie.
254     */
255    public function subscriber_logout() {
256        if ( ! class_exists( 'Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service' ) ) {
257            return;
258        }
259
260        Abstract_Token_Subscription_Service::clear_token_cookie();
261    }
262
263    /**
264     * Sets up the custom post types for the module.
265     */
266    private function setup_cpts() {
267        /*
268         * PLAN data structure.
269         */
270        $capabilities = array(
271            'edit_post'          => 'edit_posts',
272            'read_post'          => 'read_private_posts',
273            'delete_post'        => 'delete_posts',
274            'edit_posts'         => 'edit_posts',
275            'edit_others_posts'  => 'edit_others_posts',
276            'publish_posts'      => 'publish_posts',
277            'read_private_posts' => 'read_private_posts',
278        );
279        $order_args   = array(
280            'label'               => esc_html__( 'Plan', 'jetpack' ),
281            'description'         => esc_html__( 'Recurring Payments plans', 'jetpack' ),
282            'supports'            => array( 'title', 'custom-fields', 'content' ),
283            'hierarchical'        => false,
284            'public'              => false,
285            'show_ui'             => false,
286            'show_in_menu'        => false,
287            'show_in_admin_bar'   => false,
288            'show_in_nav_menus'   => false,
289            'can_export'          => true,
290            'has_archive'         => false,
291            'exclude_from_search' => true,
292            'publicly_queryable'  => false,
293            'rewrite'             => false,
294            'capabilities'        => $capabilities,
295            'show_in_rest'        => false,
296        );
297        register_post_type( self::$post_type_plan, $order_args );
298        $coupon_args = array(
299            'label'               => esc_html__( 'Coupon', 'jetpack' ),
300            'description'         => esc_html__( 'Memberships coupons', 'jetpack' ),
301            'supports'            => array( 'title', 'custom-fields', 'content' ),
302            'hierarchical'        => false,
303            'public'              => false,
304            'show_ui'             => false,
305            'show_in_menu'        => false,
306            'show_in_admin_bar'   => false,
307            'show_in_nav_menus'   => false,
308            'can_export'          => true,
309            'has_archive'         => false,
310            'exclude_from_search' => true,
311            'publicly_queryable'  => false,
312            'rewrite'             => false,
313            'capabilities'        => $capabilities,
314            'show_in_rest'        => false,
315        );
316        register_post_type( self::$post_type_coupon, $coupon_args );
317    }
318
319    /**
320     * Allows custom post types to be used by REST API.
321     *
322     * @param array $post_types - other post types.
323     *
324     * @see hook 'rest_api_allowed_post_types'
325     * @return array
326     */
327    public function allow_rest_api_types( $post_types ) {
328        $post_types[] = self::$post_type_plan;
329        $post_types[] = self::$post_type_coupon;
330
331        return $post_types;
332    }
333
334    /**
335     * Allows custom meta fields to sync.
336     *
337     * @param array $post_meta - previously changet post meta.
338     *
339     * @return array
340     */
341    public function allow_sync_post_meta( $post_meta ) {
342        $meta_keys_plans = array_map(
343            array( $this, 'return_meta' ),
344            self::get_plan_property_mapping()
345        );
346
347        $meta_coupons_prefix = self::$post_type_coupon . '_';
348        $meta_keys_coupons   = array(
349            $meta_coupons_prefix . 'coupon_code',
350            $meta_coupons_prefix . 'can_be_combined',
351            $meta_coupons_prefix . 'first_time_purchase_only',
352            $meta_coupons_prefix . 'limit_per_user',
353            $meta_coupons_prefix . 'discount_type',
354            $meta_coupons_prefix . 'discount_value',
355            $meta_coupons_prefix . 'discount_percentage',
356            $meta_coupons_prefix . 'discount_currency',
357            $meta_coupons_prefix . 'start_date',
358            $meta_coupons_prefix . 'end_date',
359            $meta_coupons_prefix . 'plan_ids_allow_list',
360            $meta_coupons_prefix . 'duration',
361            $meta_coupons_prefix . 'email_allow_list',
362            $meta_coupons_prefix . 'is_deleted',
363            $meta_coupons_prefix . 'is_sandboxed',
364        );
365
366        return array_merge(
367            $post_meta,
368            array_values( $meta_keys_plans ),
369            $meta_keys_coupons
370        );
371    }
372
373    /**
374     * This returns meta attribute of passet array.
375     * Used for array functions.
376     *
377     * @param array $map - stuff.
378     *
379     * @return mixed
380     */
381    public function return_meta( $map ) {
382        return $map['meta'];
383    }
384
385    /**
386     * Show an error to the user (or embed a clue in the HTML) when the button does not get rendered properly.
387     *
388     * @param WP_Error $error The error message with error code.
389     * @return string The error message rendered as HTML.
390     */
391    public function render_button_error( $error ) {
392        if ( static::user_can_edit() ) {
393            return '<div><strong>Jetpack Memberships Error: ' . $error->get_error_code() . '</strong><br />' . $error->get_error_message() . '</div>';
394        }
395        return '<div>Sorry! This product is not available for purchase at this time.</div><!-- Jetpack Memberships Error: ' . $error->get_error_code() . ' -->';
396    }
397
398    /**
399     * Renders a preview of the Recurring Payment button, which is not hooked
400     * up to the subscription url. Used to preview the block on the frontend
401     * for site editors when Stripe has not been connected.
402     *
403     * @param array  $attrs - attributes in the shortcode.
404     * @param string $content - Recurring Payment block content.
405     *
406     * @return string|void
407     */
408    public function render_button_preview( $attrs, $content = null ) {
409        if ( ! empty( $content ) ) {
410            $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
411            $content  = str_replace( 'recurring-payments-id', $block_id, $content );
412            $content  = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
413            return $content;
414        }
415        return $this->deprecated_render_button_v1( $attrs, null );
416    }
417
418    /**
419     * Determines whether the button preview should be rendered. Returns true
420     * if the user has editing permissions, the button is not configured correctly
421     * (because it requires a plan upgrade or Stripe connection), and the
422     * button is a child of a Premium Content block.
423     *
424     * @param WP_Block $block Recurring Payments block instance.
425     *
426     * @return boolean
427     */
428    public function should_render_button_preview( $block ) {
429        $user_can_edit              = static::user_can_edit();
430        $requires_stripe_connection = ! static::has_connected_account();
431
432        $jetpack_ready = ! self::is_enabled_jetpack_recurring_payments();
433
434        $is_premium_content_child = false;
435        if ( isset( $block ) && isset( $block->context['isPremiumContentChild'] ) ) {
436            $is_premium_content_child = (int) $block->context['isPremiumContentChild'];
437        }
438
439        return $is_premium_content_child &&
440            $user_can_edit &&
441            $requires_stripe_connection &&
442            $jetpack_ready;
443    }
444
445    /**
446     * Callback that parses the membership purchase shortcode.
447     *
448     * @param array    $attributes - attributes in the shortcode. `id` here is the CPT id of the plan.
449     * @param string   $content - Recurring Payment block content.
450     * @param WP_Block $block - Recurring Payment block instance.
451     *
452     * @return string|void - HTML for the button, void removes the button.
453     */
454    public function render_button( $attributes, $content = null, $block = null ) {
455        Jetpack_Gutenberg::load_assets_as_required( self::$button_block_name );
456
457        if ( $this->should_render_button_preview( $block ) ) {
458            return $this->render_button_preview( $attributes, $content );
459        }
460
461        if ( empty( $attributes['planId'] ) && empty( $attributes['planIds'] ) ) {
462            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' ) ) );
463        }
464
465        // This is string of '+` separated plan ids. Loop through them and
466        // filter out the ones that are not valid.
467        $plan_ids = array();
468        if ( ! empty( $attributes['planIds'] ) ) {
469            $plan_ids = $attributes['planIds'];
470        } elseif ( ! empty( $attributes['planId'] ) ) {
471            $plan_ids = explode( '+', $attributes['planId'] );
472        }
473        $valid_plans = array();
474        foreach ( $plan_ids as $plan_id ) {
475            if ( ! is_numeric( $plan_id ) ) {
476                continue;
477            }
478            $product = get_post( $plan_id );
479            if ( ! $product ) {
480                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' ) ) );
481            }
482            if ( is_wp_error( $product ) ) {
483                '@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
484                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' ) ) );
485            }
486            if ( $product->post_type !== self::$post_type_plan ) {
487                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' ) ) );
488            }
489            if ( 'publish' !== $product->post_status ) {
490                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' ) ) );
491            }
492            $valid_plans[] = $plan_id;
493        }
494
495        // If none are valid, return.
496        // (Returning like this makes the button disappear.)
497        if ( empty( $valid_plans ) ) {
498            return;
499        }
500        $plan_id = implode( '+', $valid_plans );
501
502        if ( ! empty( $content ) ) {
503            $block_id      = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
504            $content       = str_replace( 'recurring-payments-id', $block_id, $content );
505            $content       = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
506            $subscribe_url = $this->get_subscription_url( $plan_id );
507
508            $content = preg_replace( '/(href=".*")/U', 'href="' . $subscribe_url . '"', $content );
509            $content = wp_kses_post( $content );
510
511            return $content;
512        }
513
514        return $this->deprecated_render_button_v1( $attributes, $plan_id );
515    }
516
517    /**
518     * Render email callback.
519     *
520     * @param string $block_content The block content.
521     * @param array  $parsed_block  The parsed block data.
522     * @param object $rendering_context The email rendering context.
523     *
524     * @return string
525     */
526    public function render_button_email( $block_content, array $parsed_block, $rendering_context ) {
527        // Check for the required renderers.
528        if ( ! function_exists( '\Automattic\Jetpack\Extensions\Button\render_email' ) || ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Button' ) ) {
529            return '';
530        }
531
532        // Get the first inner block, which should be the button block.
533        $button_block = $parsed_block['innerBlocks'][0] ?? array();
534
535        // We should only accept button blocks.
536        if ( empty( $button_block['blockName'] ) || 'jetpack/button' !== $button_block['blockName'] ) {
537            return '';
538        }
539
540        // We need attributes.
541        if ( ! isset( $button_block['attrs'] ) || ! is_array( $button_block['attrs'] ) ) {
542            return '';
543        }
544
545        // If the button block is missing text or url, return empty string.
546        if ( empty( $button_block['attrs']['text'] ) || empty( $button_block['attrs']['url'] ) ) {
547            return '';
548        }
549
550        // Reuse the button block's email rendering method.
551        return \Automattic\Jetpack\Extensions\Button\render_email( $block_content, $button_block, $rendering_context );
552    }
553
554    /**
555     * Builds subscription URL for this membership using the current blog and
556     * supplied plan IDs.
557     *
558     * @param integer $plan_id - Unique ID for the plan being subscribed to.
559     * @return string
560     */
561    public function get_subscription_url( $plan_id ) {
562        global $wp;
563
564        return add_query_arg(
565            array(
566                'blog'     => esc_attr( self::get_blog_id() ),
567                'plan'     => esc_attr( $plan_id ),
568                'lang'     => esc_attr( get_locale() ),
569                'pid'      => esc_attr( get_the_ID() ), // Needed for analytics purposes.
570                'redirect' => esc_attr( rawurlencode( home_url( $wp->request ) ) ), // Needed for redirect back in case of redirect-based flow.
571            ),
572            'https://subscribe.wordpress.com/memberships/'
573        );
574    }
575
576    /**
577     * Renders a deprecated legacy version of the button HTML.
578     *
579     * @param array   $attrs - Array containing the Recurring Payment block attributes.
580     * @param integer $plan_id - Unique plan ID the membership is for.
581     *
582     * @return string
583     */
584    public function deprecated_render_button_v1( $attrs, $plan_id ) {
585        $button_label = isset( $attrs['submitButtonText'] )
586            ? $attrs['submitButtonText']
587            : __( 'Your contribution', 'jetpack' );
588
589        $button_styles = array();
590        if ( ! empty( $attrs['customBackgroundButtonColor'] ) ) {
591            array_push(
592                $button_styles,
593                sprintf(
594                    'background-color: %s',
595                    sanitize_hex_color( $attrs['customBackgroundButtonColor'] )
596                )
597            );
598        }
599        if ( ! empty( $attrs['customTextButtonColor'] ) ) {
600            array_push(
601                $button_styles,
602                sprintf(
603                    'color: %s',
604                    sanitize_hex_color( $attrs['customTextButtonColor'] )
605                )
606            );
607        }
608        $button_styles = implode( ';', $button_styles );
609
610        return sprintf(
611            '<div class="%1$s"><a role="button" href="%2$s" class="%3$s" style="%4$s">%5$s</a></div>',
612            esc_attr(
613                Blocks::classes(
614                    self::$button_block_name,
615                    $attrs,
616                    array( 'wp-block-button' )
617                )
618            ),
619            esc_url( $this->get_subscription_url( $plan_id ) ),
620            isset( $attrs['submitButtonClasses'] ) ? esc_attr( $attrs['submitButtonClasses'] ) : 'wp-block-button__link',
621            esc_attr( $button_styles ),
622            wp_kses( $button_label, self::$tags_allowed_in_the_button )
623        );
624    }
625
626    /**
627     * Get current blog id.
628     *
629     * @return int
630     */
631    public static function get_blog_id() {
632        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
633            return get_current_blog_id();
634        }
635
636        return Jetpack_Options::get_option( 'id' );
637    }
638
639    /**
640     * Get the id of the connected payment acount (Stripe etc).
641     *
642     * @return bool
643     */
644    public static function has_connected_account() {
645
646        // This is the primary solution.
647        $has_option = get_option( self::$has_connected_account_option_name, false ) ? true : false;
648        if ( $has_option ) {
649            return true;
650        }
651
652        return false;
653    }
654
655    /**
656     * Get the post access level
657     *
658     * If no ID is provided, the method tries to get it from the global post object.
659     *
660     * @param int|null $post_id The ID of the post. Default is null.
661     *
662     * @return string the actual post access level (see projects/plugins/jetpack/extensions/blocks/subscriptions/constants.js for the values).
663     */
664    public static function get_post_access_level( $post_id = null ) {
665        if ( ! $post_id ) {
666            $post_id = get_the_ID();
667        }
668        if ( ! $post_id ) {
669            return Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
670        }
671
672        $blog_id   = get_current_blog_id();
673        $cache_key = $blog_id . '_' . $post_id;
674
675        if ( isset( self::$post_access_level_cache[ $cache_key ] ) ) {
676            return self::$post_access_level_cache[ $cache_key ];
677        }
678
679        $post_access_level = get_post_meta( $post_id, self::$post_access_level_meta_name, true );
680        if ( empty( $post_access_level ) ) {
681            $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
682        }
683
684        self::$post_access_level_cache[ $cache_key ] = $post_access_level;
685
686        return $post_access_level;
687    }
688
689    /**
690     * Get the post tier plan
691     *
692     * If no ID is provided, the method tries to get it from the global post object.
693     *
694     * @param int|null $post_id The ID of the post. Default is null.
695     *
696     * @return WP_Post|null the actual post tier.
697     */
698    public static function get_post_tier( $post_id = null ) {
699        if ( ! $post_id ) {
700            $post_id = get_the_ID();
701        }
702
703        if ( ! $post_id ) {
704            return null;
705        }
706
707        $post_tier_id = get_post_meta( $post_id, self::$post_access_tier_meta_name, true );
708        if ( empty( $post_tier_id ) ) {
709            return null;
710        }
711
712        return get_post( $post_tier_id );
713    }
714
715    /**
716     * Determines whether the current user can edit.
717     *
718     * @return bool Whether the user can edit.
719     */
720    public static function user_can_edit() {
721        $user = wp_get_current_user();
722        // phpcs:ignore ImportDetection.Imports.RequireImports.Symbol
723        return 0 !== $user->ID && current_user_can( 'edit_post', get_the_ID() );
724    }
725
726    /**
727     * Clears the static cache for all users or for a given user.
728     *
729     * @param int|null $user_id The user_id to unset in the cache, otherwise the entire static cache is cleared.
730     * @return void
731     */
732    public static function clear_cache( ?int $user_id = null ) {
733        if ( empty( $user_id ) ) {
734            self::$user_is_paid_subscriber_cache = array();
735            self::$user_can_view_post_cache      = array();
736            return;
737        }
738        unset( self::$user_is_paid_subscriber_cache[ $user_id ] );
739        unset( self::$user_can_view_post_cache[ $user_id ] );
740    }
741
742    /**
743     * Determines whether the current user is a paid subscriber and caches the result.
744     *
745     * @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.
746     * @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).
747     * @return bool Whether the post can be viewed
748     */
749    public static function user_is_paid_subscriber( $valid_plan_ids = array(), $user_id = null ) {
750        if ( empty( $user_id ) ) {
751            $user_id = get_current_user_id();
752            if ( empty( $user_id ) ) {
753                return false;
754            }
755        }
756        // sort and stringify sorted valid plan ids to use as a cache key
757        sort( $valid_plan_ids );
758        $cache_key = $user_id . '_' . implode( ',', $valid_plan_ids );
759        if ( ! isset( self::$user_is_paid_subscriber_cache[ $cache_key ] ) ) {
760            require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
761            if ( empty( $valid_plan_ids ) ) {
762                $valid_plan_ids = self::get_all_newsletter_plan_ids();
763            }
764            $paywall            = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service( $user_id );
765            $is_paid_subscriber = $paywall->visitor_can_view_content( $valid_plan_ids, Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS );
766            self::$user_is_paid_subscriber_cache[ $cache_key ] = $is_paid_subscriber;
767        }
768        return self::$user_is_paid_subscriber_cache[ $cache_key ];
769    }
770
771    /**
772     * Determines whether the current user has a pending subscription.
773     *
774     * @return bool Whether the user has a pending subscription
775     */
776    public static function user_is_pending_subscriber() {
777        require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
778        $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
779        return $subscription_service->is_current_user_pending_subscriber();
780    }
781
782    /**
783     * Determines whether the current user can view the post based on the newsletter access level
784     * and caches the result.
785     *
786     * @param int|null $post_id Explicit post id to check against.
787     *
788     * @return bool Whether the post can be viewed
789     */
790    public static function user_can_view_post( $post_id = null ) {
791        $user_id = get_current_user_id();
792        if ( null === $post_id ) {
793            $post_id = get_the_ID();
794        }
795
796        if ( false === $post_id ) {
797            $post_id = 0;
798        }
799
800        $cache_key = sprintf( '%d_%d', $user_id, $post_id );
801        if ( isset( self::$user_can_view_post_cache[ $cache_key ] ) ) {
802            return self::$user_can_view_post_cache[ $cache_key ];
803        }
804
805        $post_access_level = self::get_post_access_level( $post_id );
806        if ( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
807            self::$user_can_view_post_cache[ $cache_key ] = true;
808            return true;
809        }
810
811        // we are sending the post to subscribers so the user is a subscriber
812        if ( defined( 'WPCOM_SENDING_POST_TO_SUBSCRIBERS' ) && WPCOM_SENDING_POST_TO_SUBSCRIBERS && Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS === $post_access_level ) {
813            self::$user_can_view_post_cache[ $cache_key ] = true;
814            return true;
815        }
816
817        require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
818        $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
819
820        $all_newsletters_plan_ids = self::get_all_newsletter_plan_ids();
821
822        if ( 0 === count( $all_newsletters_plan_ids ) &&
823            Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS === $post_access_level ||
824            Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS === $post_access_level
825        ) {
826            // The post is paywalled but there is no newsletter plans on the site.
827            // We downgrade the post level to subscribers-only
828            $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS;
829        }
830
831        $can_view_post = $paywall->visitor_can_view_content( $all_newsletters_plan_ids, $post_access_level );
832
833        self::$user_can_view_post_cache[ $cache_key ] = $can_view_post;
834        return $can_view_post;
835    }
836
837    /**
838     * Whether Recurring Payments are enabled. True if the block
839     * is supported by the site's plan, or if it is a Jetpack site
840     * and the feature to enable upgrade nudges is active.
841     *
842     * @return bool
843     */
844    public static function is_enabled_jetpack_recurring_payments() {
845        $api_available = ( new Host() )->is_wpcom_simple() || Jetpack::is_connection_ready();
846        return $api_available;
847    }
848
849    /**
850     * Whether to enable the blocks in the editor.
851     * All Monetize blocks (except Simple Payments) need a user with at least `edit_posts` capability
852     *
853     * @return bool
854     */
855    public static function should_enable_monetize_blocks_in_editor() {
856        if ( ! is_admin() ) {
857            // We enable the block for the front-end in all cases
858            return true;
859
860        }
861
862        $is_offline_mode                  = ( new Status() )->is_offline_mode();
863        $enable_monetize_blocks_in_editor = ( new Host() )->is_wpcom_simple() || ( ! $is_offline_mode );
864        return $enable_monetize_blocks_in_editor;
865    }
866
867    /**
868     * Whether site has any paid plan.
869     *
870     * @param string $type - Type of a plan for which site is configured. For now supports empty and newsletter.
871     *
872     * @return bool
873     */
874    public static function has_configured_plans_jetpack_recurring_payments( $type = '' ) {
875        if ( ! self::is_enabled_jetpack_recurring_payments() ) {
876            return false;
877        }
878        $query = array(
879            'post_type'      => self::$post_type_plan,
880            'posts_per_page' => 1,
881        );
882
883        // We want to see if user has any plan marked as a newsletter set up.
884        if ( 'newsletter' === $type ) {
885            $query['meta_key']   = 'jetpack_memberships_site_subscriber';
886            $query['meta_value'] = true;
887        }
888
889        $plans = get_posts( $query );
890        return ( is_countable( $plans ) && count( $plans ) > 0 );
891    }
892
893    /**
894     * Return the list of plan posts
895     *
896     * @return WP_Post[]|WP_Error
897     */
898    public static function get_all_plans() {
899        if ( ! self::is_enabled_jetpack_recurring_payments() ) {
900            return array();
901        }
902
903        // We can retrieve the data directly except on a Jetpack/Atomic cached site or
904        $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
905        if ( ! $is_cached_site ) {
906            return get_posts(
907                array(
908                    'posts_per_page' => -1,
909                    'post_type'      => self::$post_type_plan,
910                )
911            );
912        } else {
913            // On cached site on WPCOM
914            require_lib( 'memberships' );
915            return Memberships_Product::get_plans_posts_list( get_current_blog_id() );
916        }
917    }
918
919    /**
920     * Return all membership plans ids (deleted or not)
921     * This function is used both on WPCOM or on Jetpack self-hosted.
922     * Depending on the environment we need to mitigate where the data is retrieved from.
923     *
924     * @param bool $allow_deleted Whether to allow deleted plans to be returned. Defaults to true.
925     *
926     * @return array
927     */
928    public static function get_all_newsletter_plan_ids( $allow_deleted = true ) {
929
930        if ( ! self::is_enabled_jetpack_recurring_payments() ) {
931            return array();
932        }
933
934        // We can retrieve the data directly except on a Jetpack/Atomic cached site or
935        $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
936        if ( ! $is_cached_site ) {
937            $meta_query = array(
938                array(
939                    'key'   => 'jetpack_memberships_type',
940                    'value' => self::$type_tier,
941                ),
942            );
943
944            if ( $allow_deleted === false ) {
945                $meta_query[] = array(
946                    'key'     => 'jetpack_memberships_is_deleted',
947                    'compare' => 'NOT EXISTS',
948                );
949            }
950
951            return get_posts(
952                array(
953                    'posts_per_page' => -1,
954                    'fields'         => 'ids',
955                    'post_type'      => self::$post_type_plan,
956                    'meta_query'     => $meta_query,
957                )
958            );
959
960        } else {
961            // On cached site on WPCOM
962            require_lib( 'memberships' );
963            $list = Memberships_Product::get_product_list( get_current_blog_id(), self::$type_tier, null, $allow_deleted );
964
965            if ( is_wp_error( $list ) ) {
966                return array();
967            }
968
969            return array_map(
970                function ( $product ) {
971                    return $product['id'];
972                }, // Returning only post ids
973                $list
974            );
975        }
976    }
977
978    /**
979     * Register the Recurring Payments Gutenberg block
980     */
981    public function register_gutenberg_block() {
982        // This gate was introduced to prevent duplicate registration. A race condition exists where
983        // the registration that happens via extensions/blocks/recurring-payments/recurring-payments.php
984        // was adding the registration action after the action had been run in some contexts.
985        if ( self::$has_registered_block ) {
986            return;
987        }
988
989        if ( self::is_enabled_jetpack_recurring_payments() ) {
990            Blocks::jetpack_register_block(
991                'jetpack/recurring-payments',
992                array(
993                    'render_callback'       => array( $this, 'render_button' ),
994                    'render_email_callback' => array( $this, 'render_button_email' ),
995                    'uses_context'          => array( 'isPremiumContentChild' ),
996                    'provides_context'      => array(
997                        'jetpack/parentBlockWidth' => 'width',
998                    ),
999                )
1000            );
1001        } else {
1002            Jetpack_Gutenberg::set_extension_unavailable(
1003                'recurring-payments',
1004                'missing_plan',
1005                array(
1006                    'required_feature' => 'memberships',
1007                    'required_plan'    => self::$required_plan,
1008                )
1009            );
1010        }
1011
1012        self::$has_registered_block = true;
1013    }
1014
1015    /**
1016     * Transforms a number into it's short human-readable version.
1017     *
1018     * @param int $subscribers_total The extrapolated excerpt string.
1019     *
1020     * @return string Human-readable version of the number. ie. 1.9 M.
1021     */
1022    public static function get_join_others_text( $subscribers_total ) {
1023        if ( $subscribers_total >= 1000000 ) {
1024            /* translators: %s: number of folks following the blog, millions(M) with one decimal. i.e. 1.1 */
1025            return sprintf( __( 'Join %sM other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000000, 1 ) ) );
1026        }
1027        if ( $subscribers_total >= 10000 ) {
1028            /* translators: %s: number of folks following the blog, thousands(K) with one decimal. i.e. 1.1 */
1029            return sprintf( __( 'Join %sK other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000, 1 ) ) );
1030        }
1031
1032        /* translators: %s: number of folks following the blog */
1033        return sprintf( _n( 'Join %s other subscriber', 'Join %s other subscribers', $subscribers_total, 'jetpack' ), number_format_i18n( $subscribers_total ) );
1034    }
1035
1036    /**
1037     * Returns the email of the current user.
1038     *
1039     * @return string
1040     */
1041    public static function get_current_user_email() {
1042        require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1043        $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1044        return $subscription_service->get_subscriber_email();
1045    }
1046
1047    /**
1048     * Returns if the current user is subscribed or not.
1049     *
1050     * @return boolean
1051     */
1052    public static function is_current_user_subscribed() {
1053        require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1054        $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1055        return $subscription_service->is_current_user_subscribed();
1056    }
1057}
1058Jetpack_Memberships::get_instance();