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