PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.0-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.0-beta
16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / modules / memberships / class-jetpack-memberships.php
jetpack / modules / memberships Last commit date
class-jetpack-memberships.php 4 days ago
class-jetpack-memberships.php
1125 lines
1 <?php
2 /**
3 * Jetpack_Memberships: wrapper for memberships functions.
4 *
5 * @package Jetpack
6 * @since 7.3.0
7 */
8
9 use Automattic\Jetpack\Blocks;
10 use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service;
11 use Automattic\Jetpack\Status;
12 use Automattic\Jetpack\Status\Host;
13 use Automattic\Jetpack\Status\Request;
14 use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_LEVEL_ACCESS_SETTINGS;
15 use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_TIER_ID_SETTINGS;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit( 0 );
19 }
20
21 require_once __DIR__ . '/../../extensions/blocks/subscriptions/constants.php';
22
23 /**
24 * Class Jetpack_Memberships
25 * This class represents the Memberships functionality.
26 */
27 class Jetpack_Memberships {
28 /**
29 * CSS class prefix to use in the styling.
30 *
31 * @var string
32 */
33 public static $css_classname_prefix = 'jetpack-memberships';
34 /**
35 * Our CPT type for the product (plan).
36 *
37 * @var string
38 */
39 public static $post_type_plan = 'jp_mem_plan';
40
41 /**
42 * Our CPT type for the product (plan).
43 *
44 * @var string
45 */
46 public static $post_type_coupon = 'memberships_coupon';
47
48 /**
49 * Tier type for plans
50 *
51 * @var string
52 */
53 public static $type_tier = 'tier';
54
55 /**
56 * Option stores status for memberships (Stripe, etc.).
57 *
58 * @var string
59 */
60 public static $has_connected_account_option_name = 'jetpack-memberships-has-connected-account';
61
62 /**
63 * Post meta that will store the level of access for newsletters
64 *
65 * @var string
66 */
67 public static $post_access_level_meta_name = META_NAME_FOR_POST_LEVEL_ACCESS_SETTINGS;
68
69 /**
70 * Post meta that will store the tier ID of access for newsletters
71 *
72 * @var string
73 */
74 public static $post_access_tier_meta_name = META_NAME_FOR_POST_TIER_ID_SETTINGS;
75
76 /**
77 * Button block type to use.
78 *
79 * @var string
80 */
81 private static $button_block_name = 'recurring-payments';
82
83 /**
84 * These are defaults for wp_kses ran on the membership button.
85 *
86 * @var array
87 */
88 private static $tags_allowed_in_the_button = array( 'br' => array() );
89
90 /**
91 * Allowed HTML tags for a rendered tier description. Mirrors the wp.com
92 * subscribe modal's allowlist so the rendered markdown stays consistent
93 * across surfaces.
94 *
95 * @var array
96 */
97 const TIER_DESCRIPTION_ALLOWED_HTML = array(
98 'p' => array(),
99 'br' => array(),
100 'ul' => array(),
101 'ol' => array(),
102 'li' => array(),
103 'strong' => array(),
104 'em' => array(),
105 'del' => array(),
106 'code' => array(),
107 'blockquote' => array(),
108 'a' => array(
109 'href' => true,
110 'rel' => true,
111 'target' => true,
112 ),
113 );
114
115 /**
116 * The minimum required plan for this Gutenberg block.
117 *
118 * @var string Plan slug
119 */
120 private static $required_plan;
121
122 /**
123 * Track recurring payments block registration.
124 *
125 * @var boolean True if block registration has been executed.
126 */
127 private static $has_registered_block = false;
128
129 /**
130 * Classic singleton pattern
131 *
132 * @var Jetpack_Memberships
133 */
134 private static $instance;
135
136 /**
137 * Cached results of user_can_view_post() method.
138 *
139 * @var array
140 */
141 private static $user_can_view_post_cache = array();
142
143 /**
144 * Cached results of user_is_paid_subscriber() method.
145 *
146 * @var array
147 */
148 private static $user_is_paid_subscriber_cache = array();
149
150 /**
151 * Cached results of get_post_access_level method.
152 *
153 * @var array
154 */
155 private static $post_access_level_cache = array();
156
157 /**
158 * Clear cached results of get_post_access_level method.
159 */
160 public static function clear_post_access_level_cache() {
161 self::$post_access_level_cache = array();
162 }
163
164 /**
165 * Currencies we support and Stripe's minimum amount for a transaction in that currency.
166 *
167 * @link https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
168 *
169 * List has to be in with `SUPPORTED_CURRENCIES` in extensions/shared/currencies.js.
170 */
171 const SUPPORTED_CURRENCIES = array(
172 'USD' => 0.5,
173 'AUD' => 0.5,
174 'BRL' => 0.5,
175 'CAD' => 0.5,
176 'CHF' => 0.5,
177 'DKK' => 2.5,
178 'EUR' => 0.5,
179 'GBP' => 0.3,
180 'HKD' => 4.0,
181 'INR' => 0.5,
182 'JPY' => 50,
183 'MXN' => 10,
184 'NOK' => 3.0,
185 'NZD' => 0.5,
186 'PLN' => 2.0,
187 'SEK' => 3.0,
188 'SGD' => 0.5,
189 'CZK' => 15.0,
190 'HUF' => 175.0,
191 'TWD' => 10.0,
192 'IDR' => 0,
193 'ILS' => 0,
194 'PHP' => 0,
195 'RUB' => 0,
196 'TRY' => 0,
197 );
198
199 /**
200 * Jetpack_Memberships constructor.
201 */
202 private function __construct() {}
203
204 /**
205 * The actual constructor initializing the object.
206 *
207 * @return Jetpack_Memberships
208 */
209 public static function get_instance() {
210 if ( ! self::$instance ) {
211 self::$instance = new self();
212 self::$instance->register_init_hook();
213 // Yes, `pro-plan` with a dash, `jetpack_personal` with an underscore. Check the v1.5 endpoint to verify.
214 $wpcom_plan_slug = defined( 'ENABLE_PRO_PLAN' ) ? 'pro-plan' : 'personal-bundle';
215 self::$required_plan = ( new Host() )->is_wpcom_simple() ? $wpcom_plan_slug : 'jetpack_personal';
216 }
217
218 return self::$instance;
219 }
220 /**
221 * Get the map that defines the shape of CPT post. keys are names of fields and
222 * 'meta' is the name of actual WP post meta field that corresponds.
223 *
224 * @return array
225 */
226 private static function get_plan_property_mapping() {
227 $meta_prefix = 'jetpack_memberships_';
228 $properties = array(
229 'price' => array(
230 'meta' => $meta_prefix . 'price',
231 ),
232 'currency' => array(
233 'meta' => $meta_prefix . 'currency',
234 ),
235 'site_subscriber' => array(
236 'meta' => $meta_prefix . 'site_subscriber',
237 ),
238 'product_id' => array(
239 'meta' => $meta_prefix . 'product_id',
240 ),
241 'tier' => array(
242 'meta' => $meta_prefix . 'tier',
243 ),
244 'is_deleted' => array(
245 'meta' => $meta_prefix . 'is_deleted',
246 ),
247 'is_sandboxed' => array(
248 'meta' => $meta_prefix . 'is_sandboxed',
249 ),
250 );
251 return $properties;
252 }
253
254 /**
255 * Inits further hooks on init hook.
256 */
257 private function register_init_hook() {
258 add_action( 'init', array( $this, 'init_hook_action' ) );
259 add_action( 'jetpack_register_gutenberg_extensions', array( $this, 'register_gutenberg_block' ) );
260 // phpcs:ignore WPCUT.SwitchBlog.SwitchBlog -- wpcom flags **every** use of switch_blog, apparently expecting valid instances to ignore or suppress the sniff.
261 add_action( 'switch_blog', array( $this, 'clear_post_access_level_cache' ) );
262 }
263
264 /**
265 * Actual hooks initializing on init.
266 */
267 public function init_hook_action() {
268 add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
269 add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
270 $this->setup_cpts();
271
272 if ( Jetpack::is_module_active( 'subscriptions' ) && Request::is_frontend() ) {
273 add_action( 'wp_logout', array( $this, 'subscriber_logout' ) );
274 }
275 }
276
277 /**
278 * Logs the subscriber out by clearing out the premium content cookie.
279 */
280 public function subscriber_logout() {
281 if ( ! class_exists( 'Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service' ) ) {
282 return;
283 }
284
285 Abstract_Token_Subscription_Service::clear_token_cookie();
286 }
287
288 /**
289 * Sets up the custom post types for the module.
290 */
291 private function setup_cpts() {
292 /*
293 * PLAN data structure.
294 */
295 $capabilities = array(
296 'edit_post' => 'edit_posts',
297 'read_post' => 'read_private_posts',
298 'delete_post' => 'delete_posts',
299 'edit_posts' => 'edit_posts',
300 'edit_others_posts' => 'edit_others_posts',
301 'publish_posts' => 'publish_posts',
302 'read_private_posts' => 'read_private_posts',
303 );
304 $order_args = array(
305 'label' => esc_html__( 'Plan', 'jetpack' ),
306 'description' => esc_html__( 'Recurring Payments plans', 'jetpack' ),
307 'supports' => array( 'title', 'custom-fields', 'content' ),
308 'hierarchical' => false,
309 'public' => false,
310 'show_ui' => false,
311 'show_in_menu' => false,
312 'show_in_admin_bar' => false,
313 'show_in_nav_menus' => false,
314 'can_export' => true,
315 'has_archive' => false,
316 'exclude_from_search' => true,
317 'publicly_queryable' => false,
318 'rewrite' => false,
319 'capabilities' => $capabilities,
320 'show_in_rest' => false,
321 );
322 register_post_type( self::$post_type_plan, $order_args );
323 $coupon_args = array(
324 'label' => esc_html__( 'Coupon', 'jetpack' ),
325 'description' => esc_html__( 'Memberships coupons', 'jetpack' ),
326 'supports' => array( 'title', 'custom-fields', 'content' ),
327 'hierarchical' => false,
328 'public' => false,
329 'show_ui' => false,
330 'show_in_menu' => false,
331 'show_in_admin_bar' => false,
332 'show_in_nav_menus' => false,
333 'can_export' => true,
334 'has_archive' => false,
335 'exclude_from_search' => true,
336 'publicly_queryable' => false,
337 'rewrite' => false,
338 'capabilities' => $capabilities,
339 'show_in_rest' => false,
340 );
341 register_post_type( self::$post_type_coupon, $coupon_args );
342 }
343
344 /**
345 * Allows custom post types to be used by REST API.
346 *
347 * @param array $post_types - other post types.
348 *
349 * @see hook 'rest_api_allowed_post_types'
350 * @return array
351 */
352 public function allow_rest_api_types( $post_types ) {
353 $post_types[] = self::$post_type_plan;
354 $post_types[] = self::$post_type_coupon;
355
356 return $post_types;
357 }
358
359 /**
360 * Allows custom meta fields to sync.
361 *
362 * @param array $post_meta - previously changet post meta.
363 *
364 * @return array
365 */
366 public function allow_sync_post_meta( $post_meta ) {
367 $meta_keys_plans = array_map(
368 array( $this, 'return_meta' ),
369 self::get_plan_property_mapping()
370 );
371
372 $meta_coupons_prefix = self::$post_type_coupon . '_';
373 $meta_keys_coupons = array(
374 $meta_coupons_prefix . 'coupon_code',
375 $meta_coupons_prefix . 'can_be_combined',
376 $meta_coupons_prefix . 'first_time_purchase_only',
377 $meta_coupons_prefix . 'limit_per_user',
378 $meta_coupons_prefix . 'discount_type',
379 $meta_coupons_prefix . 'discount_value',
380 $meta_coupons_prefix . 'discount_percentage',
381 $meta_coupons_prefix . 'discount_currency',
382 $meta_coupons_prefix . 'start_date',
383 $meta_coupons_prefix . 'end_date',
384 $meta_coupons_prefix . 'plan_ids_allow_list',
385 $meta_coupons_prefix . 'duration',
386 $meta_coupons_prefix . 'email_allow_list',
387 $meta_coupons_prefix . 'is_deleted',
388 $meta_coupons_prefix . 'is_sandboxed',
389 );
390
391 return array_merge(
392 $post_meta,
393 array_values( $meta_keys_plans ),
394 $meta_keys_coupons
395 );
396 }
397
398 /**
399 * This returns meta attribute of passet array.
400 * Used for array functions.
401 *
402 * @param array $map - stuff.
403 *
404 * @return mixed
405 */
406 public function return_meta( $map ) {
407 return $map['meta'];
408 }
409
410 /**
411 * Show an error to the user (or embed a clue in the HTML) when the button does not get rendered properly.
412 *
413 * @param WP_Error $error The error message with error code.
414 * @return string The error message rendered as HTML.
415 */
416 public function render_button_error( $error ) {
417 if ( static::user_can_edit() ) {
418 return '<div><strong>Jetpack Memberships Error: ' . $error->get_error_code() . '</strong><br />' . $error->get_error_message() . '</div>';
419 }
420 return '<div>Sorry! This product is not available for purchase at this time.</div><!-- Jetpack Memberships Error: ' . $error->get_error_code() . ' -->';
421 }
422
423 /**
424 * Renders a preview of the Recurring Payment button, which is not hooked
425 * up to the subscription url. Used to preview the block on the frontend
426 * for site editors when Stripe has not been connected.
427 *
428 * @param array $attrs - attributes in the shortcode.
429 * @param string $content - Recurring Payment block content.
430 *
431 * @return string|void
432 */
433 public function render_button_preview( $attrs, $content = null ) {
434 if ( ! empty( $content ) ) {
435 $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
436 $content = str_replace( 'recurring-payments-id', $block_id, $content );
437 $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
438 return $content;
439 }
440 return $this->deprecated_render_button_v1( $attrs, null );
441 }
442
443 /**
444 * Determines whether the button preview should be rendered. Returns true
445 * if the user has editing permissions, the button is not configured correctly
446 * (because it requires a plan upgrade or Stripe connection), and the
447 * button is a child of a Premium Content block.
448 *
449 * @param WP_Block $block Recurring Payments block instance.
450 *
451 * @return boolean
452 */
453 public function should_render_button_preview( $block ) {
454 $user_can_edit = static::user_can_edit();
455 $requires_stripe_connection = ! static::has_connected_account();
456
457 $jetpack_ready = ! self::is_enabled_jetpack_recurring_payments();
458
459 $is_premium_content_child = false;
460 if ( isset( $block ) && isset( $block->context['isPremiumContentChild'] ) ) {
461 $is_premium_content_child = (int) $block->context['isPremiumContentChild'];
462 }
463
464 return $is_premium_content_child &&
465 $user_can_edit &&
466 $requires_stripe_connection &&
467 $jetpack_ready;
468 }
469
470 /**
471 * Callback that parses the membership purchase shortcode.
472 *
473 * @param array $attributes - attributes in the shortcode. `id` here is the CPT id of the plan.
474 * @param string $content - Recurring Payment block content.
475 * @param WP_Block $block - Recurring Payment block instance.
476 *
477 * @return string|void - HTML for the button, void removes the button.
478 */
479 public function render_button( $attributes, $content = null, $block = null ) {
480 Jetpack_Gutenberg::load_assets_as_required( self::$button_block_name );
481
482 if ( $this->should_render_button_preview( $block ) ) {
483 return $this->render_button_preview( $attributes, $content );
484 }
485
486 if ( empty( $attributes['planId'] ) && empty( $attributes['planIds'] ) ) {
487 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' ) ) );
488 }
489
490 // This is string of '+` separated plan ids. Loop through them and
491 // filter out the ones that are not valid.
492 $plan_ids = array();
493 if ( ! empty( $attributes['planIds'] ) ) {
494 $plan_ids = $attributes['planIds'];
495 } elseif ( ! empty( $attributes['planId'] ) ) {
496 $plan_ids = explode( '+', $attributes['planId'] );
497 }
498 $valid_plans = array();
499 foreach ( $plan_ids as $plan_id ) {
500 if ( ! is_numeric( $plan_id ) ) {
501 continue;
502 }
503 $product = get_post( $plan_id );
504 if ( ! $product ) {
505 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' ) ) );
506 }
507 if ( is_wp_error( $product ) ) {
508 '@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
509 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' ) ) );
510 }
511 if ( $product->post_type !== self::$post_type_plan ) {
512 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' ) ) );
513 }
514 if ( 'publish' !== $product->post_status ) {
515 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' ) ) );
516 }
517 $valid_plans[] = $plan_id;
518 }
519
520 // If none are valid, return.
521 // (Returning like this makes the button disappear.)
522 if ( empty( $valid_plans ) ) {
523 return;
524 }
525 $plan_id = implode( '+', $valid_plans );
526
527 if ( ! empty( $content ) ) {
528 $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
529 $content = str_replace( 'recurring-payments-id', $block_id, $content );
530 $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
531 $subscribe_url = $this->get_subscription_url( $plan_id );
532
533 $content = preg_replace( '/(href=".*")/U', 'href="' . $subscribe_url . '"', $content );
534 $content = wp_kses_post( $content );
535
536 return $content;
537 }
538
539 return $this->deprecated_render_button_v1( $attributes, $plan_id );
540 }
541
542 /**
543 * Render email callback.
544 *
545 * @param string $block_content The block content.
546 * @param array $parsed_block The parsed block data.
547 * @param object $rendering_context The email rendering context.
548 *
549 * @return string
550 */
551 public function render_button_email( $block_content, array $parsed_block, $rendering_context ) {
552 // Check for the required renderers.
553 if ( ! function_exists( '\Automattic\Jetpack\Extensions\Button\render_email' ) || ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Button' ) ) {
554 return '';
555 }
556
557 // Get the first inner block, which should be the button block.
558 $button_block = $parsed_block['innerBlocks'][0] ?? array();
559
560 // We should only accept button blocks.
561 if ( empty( $button_block['blockName'] ) || 'jetpack/button' !== $button_block['blockName'] ) {
562 return '';
563 }
564
565 // We need attributes.
566 if ( ! isset( $button_block['attrs'] ) || ! is_array( $button_block['attrs'] ) ) {
567 return '';
568 }
569
570 // If the button block is missing text or url, return empty string.
571 if ( empty( $button_block['attrs']['text'] ) || empty( $button_block['attrs']['url'] ) ) {
572 return '';
573 }
574
575 // Reuse the button block's email rendering method.
576 return \Automattic\Jetpack\Extensions\Button\render_email( $block_content, $button_block, $rendering_context );
577 }
578
579 /**
580 * Builds subscription URL for this membership using the current blog and
581 * supplied plan IDs.
582 *
583 * @param integer $plan_id - Unique ID for the plan being subscribed to.
584 * @return string
585 */
586 public function get_subscription_url( $plan_id ) {
587 global $wp;
588
589 return add_query_arg(
590 array(
591 'blog' => esc_attr( self::get_blog_id() ),
592 'plan' => esc_attr( $plan_id ),
593 'lang' => esc_attr( get_locale() ),
594 'pid' => esc_attr( get_the_ID() ), // Needed for analytics purposes.
595 'redirect' => esc_attr( rawurlencode( home_url( $wp->request ) ) ), // Needed for redirect back in case of redirect-based flow.
596 ),
597 'https://subscribe.wordpress.com/memberships/'
598 );
599 }
600
601 /**
602 * Renders a deprecated legacy version of the button HTML.
603 *
604 * @param array $attrs - Array containing the Recurring Payment block attributes.
605 * @param integer $plan_id - Unique plan ID the membership is for.
606 *
607 * @return string
608 */
609 public function deprecated_render_button_v1( $attrs, $plan_id ) {
610 $button_label = $attrs['submitButtonText'] ?? __( 'Your contribution', 'jetpack' );
611
612 $button_styles = array();
613 if ( ! empty( $attrs['customBackgroundButtonColor'] ) ) {
614 array_push(
615 $button_styles,
616 sprintf(
617 'background-color: %s',
618 sanitize_hex_color( $attrs['customBackgroundButtonColor'] )
619 )
620 );
621 }
622 if ( ! empty( $attrs['customTextButtonColor'] ) ) {
623 array_push(
624 $button_styles,
625 sprintf(
626 'color: %s',
627 sanitize_hex_color( $attrs['customTextButtonColor'] )
628 )
629 );
630 }
631 $button_styles = implode( ';', $button_styles );
632
633 return sprintf(
634 '<div class="%1$s"><a role="button" href="%2$s" class="%3$s" style="%4$s">%5$s</a></div>',
635 esc_attr(
636 Blocks::classes(
637 self::$button_block_name,
638 $attrs,
639 array( 'wp-block-button' )
640 )
641 ),
642 esc_url( $this->get_subscription_url( $plan_id ) ),
643 isset( $attrs['submitButtonClasses'] ) ? esc_attr( $attrs['submitButtonClasses'] ) : 'wp-block-button__link',
644 esc_attr( $button_styles ),
645 wp_kses( $button_label, self::$tags_allowed_in_the_button )
646 );
647 }
648
649 /**
650 * Get current blog id.
651 *
652 * @return int
653 */
654 public static function get_blog_id() {
655 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
656 return get_current_blog_id();
657 }
658
659 return Jetpack_Options::get_option( 'id' );
660 }
661
662 /**
663 * Get the id of the connected payment acount (Stripe etc).
664 *
665 * @return bool
666 */
667 public static function has_connected_account() {
668
669 // This is the primary solution.
670 $has_option = get_option( self::$has_connected_account_option_name, false ) ? true : false;
671 if ( $has_option ) {
672 return true;
673 }
674
675 return false;
676 }
677
678 /**
679 * Get the post access level
680 *
681 * If no ID is provided, the method tries to get it from the global post object.
682 *
683 * @param int|null $post_id The ID of the post. Default is null.
684 *
685 * @return string the actual post access level (see projects/plugins/jetpack/extensions/blocks/subscriptions/constants.js for the values).
686 */
687 public static function get_post_access_level( $post_id = null ) {
688 if ( ! $post_id ) {
689 $post_id = get_the_ID();
690 }
691 if ( ! $post_id ) {
692 return Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
693 }
694
695 $blog_id = get_current_blog_id();
696 $cache_key = $blog_id . '_' . $post_id;
697
698 if ( isset( self::$post_access_level_cache[ $cache_key ] ) ) {
699 return self::$post_access_level_cache[ $cache_key ];
700 }
701
702 $post_access_level = get_post_meta( $post_id, self::$post_access_level_meta_name, true );
703 // Defaults to "everybody" when unset, and also when the stored value is not a
704 // string. Corrupt rows (e.g. a serialized array like a:1:{i:0;s:0:"";}) can be
705 // persisted by non-REST write paths, and an array flows unchanged into the
706 // strict string-typed `earn_user_has_access` callback on WPCOM, fataling the
707 // render. Coercing here keeps this canonical accessor's documented string
708 // contract regardless of how the meta was written.
709 if ( empty( $post_access_level ) || ! is_string( $post_access_level ) ) {
710 $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
711 }
712
713 self::$post_access_level_cache[ $cache_key ] = $post_access_level;
714
715 return $post_access_level;
716 }
717
718 /**
719 * Get the post tier plan
720 *
721 * If no ID is provided, the method tries to get it from the global post object.
722 *
723 * @param int|null $post_id The ID of the post. Default is null.
724 *
725 * @return WP_Post|null the actual post tier.
726 */
727 public static function get_post_tier( $post_id = null ) {
728 if ( ! $post_id ) {
729 $post_id = get_the_ID();
730 }
731
732 if ( ! $post_id ) {
733 return null;
734 }
735
736 $post_tier_id = get_post_meta( $post_id, self::$post_access_tier_meta_name, true );
737 if ( empty( $post_tier_id ) ) {
738 return null;
739 }
740
741 return get_post( $post_tier_id );
742 }
743
744 /**
745 * Determines whether the current user can edit.
746 *
747 * @return bool Whether the user can edit.
748 */
749 public static function user_can_edit() {
750 $user = wp_get_current_user();
751 return 0 !== $user->ID && current_user_can( 'edit_post', get_the_ID() );
752 }
753
754 /**
755 * Clears the static cache for all users or for a given user.
756 *
757 * @param int|null $user_id The user_id to unset in the cache, otherwise the entire static cache is cleared.
758 * @return void
759 */
760 public static function clear_cache( ?int $user_id = null ) {
761 if ( empty( $user_id ) ) {
762 self::$user_is_paid_subscriber_cache = array();
763 self::$user_can_view_post_cache = array();
764 return;
765 }
766 unset( self::$user_is_paid_subscriber_cache[ $user_id ] );
767 unset( self::$user_can_view_post_cache[ $user_id ] );
768 }
769
770 /**
771 * Determines whether the current user is a paid subscriber and caches the result.
772 *
773 * @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.
774 * @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).
775 * @return bool Whether the post can be viewed
776 */
777 public static function user_is_paid_subscriber( $valid_plan_ids = array(), $user_id = null ) {
778 if ( empty( $user_id ) ) {
779 $user_id = get_current_user_id();
780 if ( empty( $user_id ) ) {
781 return false;
782 }
783 }
784 // sort and stringify sorted valid plan ids to use as a cache key
785 sort( $valid_plan_ids );
786 $cache_key = $user_id . '_' . implode( ',', $valid_plan_ids );
787 if ( ! isset( self::$user_is_paid_subscriber_cache[ $cache_key ] ) ) {
788 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
789 if ( empty( $valid_plan_ids ) ) {
790 $valid_plan_ids = self::get_all_newsletter_plan_ids();
791 }
792 $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service( $user_id );
793 $is_paid_subscriber = $paywall->visitor_can_view_content( $valid_plan_ids, Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS );
794 self::$user_is_paid_subscriber_cache[ $cache_key ] = $is_paid_subscriber;
795 }
796 return self::$user_is_paid_subscriber_cache[ $cache_key ];
797 }
798
799 /**
800 * Determines whether the current user has a pending subscription.
801 *
802 * @return bool Whether the user has a pending subscription
803 */
804 public static function user_is_pending_subscriber() {
805 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
806 $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
807 return $subscription_service->is_current_user_pending_subscriber();
808 }
809
810 /**
811 * Determines whether the current user can view the post based on the newsletter access level
812 * and caches the result.
813 *
814 * @param int|null $post_id Explicit post id to check against.
815 *
816 * @return bool Whether the post can be viewed
817 */
818 public static function user_can_view_post( $post_id = null ) {
819 $user_id = get_current_user_id();
820 if ( null === $post_id ) {
821 $post_id = get_the_ID();
822 }
823
824 if ( false === $post_id ) {
825 $post_id = 0;
826 }
827
828 $cache_key = sprintf( '%d_%d', $user_id, $post_id );
829 if ( isset( self::$user_can_view_post_cache[ $cache_key ] ) ) {
830 return self::$user_can_view_post_cache[ $cache_key ];
831 }
832
833 $post_access_level = self::get_post_access_level( $post_id );
834 if ( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
835 self::$user_can_view_post_cache[ $cache_key ] = true;
836 return true;
837 }
838
839 // we are sending the post to subscribers so the user is a subscriber
840 if ( defined( 'WPCOM_SENDING_POST_TO_SUBSCRIBERS' ) && WPCOM_SENDING_POST_TO_SUBSCRIBERS && Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS === $post_access_level ) {
841 self::$user_can_view_post_cache[ $cache_key ] = true;
842 return true;
843 }
844
845 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
846 $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
847
848 $all_newsletters_plan_ids = self::get_all_newsletter_plan_ids();
849
850 if ( 0 === count( $all_newsletters_plan_ids ) &&
851 Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS === $post_access_level ||
852 Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS === $post_access_level
853 ) {
854 // The post is paywalled but there is no newsletter plans on the site.
855 // We downgrade the post level to subscribers-only
856 $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS;
857 }
858
859 $can_view_post = $paywall->visitor_can_view_content( $all_newsletters_plan_ids, $post_access_level );
860
861 self::$user_can_view_post_cache[ $cache_key ] = $can_view_post;
862 return $can_view_post;
863 }
864
865 /**
866 * Whether Recurring Payments are enabled. True if the block
867 * is supported by the site's plan, or if it is a Jetpack site
868 * and the feature to enable upgrade nudges is active.
869 *
870 * @return bool
871 */
872 public static function is_enabled_jetpack_recurring_payments() {
873 $api_available = ( new Host() )->is_wpcom_simple() || Jetpack::is_connection_ready();
874 return $api_available;
875 }
876
877 /**
878 * Whether to enable the blocks in the editor.
879 * All Monetize blocks (except Simple Payments) need a user with at least `edit_posts` capability
880 *
881 * @return bool
882 */
883 public static function should_enable_monetize_blocks_in_editor() {
884 if ( ! is_admin() ) {
885 // We enable the block for the front-end in all cases
886 return true;
887
888 }
889
890 $is_offline_mode = ( new Status() )->is_offline_mode();
891 $enable_monetize_blocks_in_editor = ( new Host() )->is_wpcom_simple() || ( ! $is_offline_mode );
892 return $enable_monetize_blocks_in_editor;
893 }
894
895 /**
896 * Whether site has any paid plan.
897 *
898 * @param string $type - Type of a plan for which site is configured. For now supports empty and newsletter.
899 *
900 * @return bool
901 */
902 public static function has_configured_plans_jetpack_recurring_payments( $type = '' ) {
903 if ( ! self::is_enabled_jetpack_recurring_payments() ) {
904 return false;
905 }
906 $query = array(
907 'post_type' => self::$post_type_plan,
908 'posts_per_page' => 1,
909 );
910
911 // We want to see if user has any plan marked as a newsletter set up.
912 if ( 'newsletter' === $type ) {
913 $query['meta_key'] = 'jetpack_memberships_site_subscriber';
914 $query['meta_value'] = true;
915 }
916
917 $plans = get_posts( $query );
918 return ( is_countable( $plans ) && count( $plans ) > 0 );
919 }
920
921 /**
922 * Return the list of plan posts
923 *
924 * @return WP_Post[]|WP_Error
925 */
926 public static function get_all_plans() {
927 if ( ! self::is_enabled_jetpack_recurring_payments() ) {
928 return array();
929 }
930
931 // We can retrieve the data directly except on a Jetpack/Atomic cached site or
932 $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
933 if ( ! $is_cached_site ) {
934 return get_posts(
935 array(
936 'posts_per_page' => -1,
937 'post_type' => self::$post_type_plan,
938 )
939 );
940 } else {
941 // On cached site on WPCOM
942 require_lib( 'memberships' );
943 return Memberships_Product::get_plans_posts_list( get_current_blog_id() );
944 }
945 }
946
947 /**
948 * Return all membership plans ids (deleted or not)
949 * This function is used both on WPCOM or on Jetpack self-hosted.
950 * Depending on the environment we need to mitigate where the data is retrieved from.
951 *
952 * @param bool $allow_deleted Whether to allow deleted plans to be returned. Defaults to true.
953 *
954 * @return array
955 */
956 public static function get_all_newsletter_plan_ids( $allow_deleted = true ) {
957
958 if ( ! self::is_enabled_jetpack_recurring_payments() ) {
959 return array();
960 }
961
962 // We can retrieve the data directly except on a Jetpack/Atomic cached site or
963 $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
964 if ( ! $is_cached_site ) {
965 $meta_query = array(
966 array(
967 'key' => 'jetpack_memberships_type',
968 'value' => self::$type_tier,
969 ),
970 );
971
972 if ( $allow_deleted === false ) {
973 $meta_query[] = array(
974 'key' => 'jetpack_memberships_is_deleted',
975 'compare' => 'NOT EXISTS',
976 );
977 }
978
979 return get_posts(
980 array(
981 'posts_per_page' => -1,
982 'fields' => 'ids',
983 'post_type' => self::$post_type_plan,
984 'meta_query' => $meta_query,
985 )
986 );
987
988 } else {
989 // On cached site on WPCOM
990 require_lib( 'memberships' );
991 $list = Memberships_Product::get_product_list( get_current_blog_id(), self::$type_tier, null, $allow_deleted );
992
993 if ( is_wp_error( $list ) ) {
994 return array();
995 }
996
997 return array_map(
998 function ( $product ) {
999 return $product['id'];
1000 }, // Returning only post ids
1001 $list
1002 );
1003 }
1004 }
1005
1006 /**
1007 * Register the Recurring Payments Gutenberg block
1008 */
1009 public function register_gutenberg_block() {
1010 // This gate was introduced to prevent duplicate registration. A race condition exists where
1011 // the registration that happens via extensions/blocks/recurring-payments/recurring-payments.php
1012 // was adding the registration action after the action had been run in some contexts.
1013 if ( self::$has_registered_block ) {
1014 return;
1015 }
1016
1017 if ( self::is_enabled_jetpack_recurring_payments() ) {
1018 Blocks::jetpack_register_block(
1019 'jetpack/recurring-payments',
1020 array(
1021 'render_callback' => array( $this, 'render_button' ),
1022 'render_email_callback' => array( $this, 'render_button_email' ),
1023 'uses_context' => array( 'isPremiumContentChild' ),
1024 'provides_context' => array(
1025 'jetpack/parentBlockWidth' => 'width',
1026 ),
1027 )
1028 );
1029 } else {
1030 Jetpack_Gutenberg::set_extension_unavailable(
1031 'recurring-payments',
1032 'missing_plan',
1033 array(
1034 'required_feature' => 'memberships',
1035 'required_plan' => self::$required_plan,
1036 )
1037 );
1038 }
1039
1040 self::$has_registered_block = true;
1041 }
1042
1043 /**
1044 * Transforms a number into it's short human-readable version.
1045 *
1046 * @param int $subscribers_total The extrapolated excerpt string.
1047 *
1048 * @return string Human-readable version of the number. ie. 1.9 M.
1049 */
1050 public static function get_join_others_text( $subscribers_total ) {
1051 if ( $subscribers_total >= 1000000 ) {
1052 /* translators: %s: number of folks following the blog, millions(M) with one decimal. i.e. 1.1 */
1053 return sprintf( __( 'Join %sM other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000000, 1 ) ) );
1054 }
1055 if ( $subscribers_total >= 10000 ) {
1056 /* translators: %s: number of folks following the blog, thousands(K) with one decimal. i.e. 1.1 */
1057 return sprintf( __( 'Join %sK other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000, 1 ) ) );
1058 }
1059
1060 /* translators: %s: number of folks following the blog */
1061 return sprintf( _n( 'Join %s other subscriber', 'Join %s other subscribers', $subscribers_total, 'jetpack' ), number_format_i18n( $subscribers_total ) );
1062 }
1063
1064 /**
1065 * Returns the email of the current user.
1066 *
1067 * @return string
1068 */
1069 public static function get_current_user_email() {
1070 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1071 $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1072 return $subscription_service->get_subscriber_email();
1073 }
1074
1075 /**
1076 * Returns if the current user is subscribed or not.
1077 *
1078 * @return boolean
1079 */
1080 public static function is_current_user_subscribed() {
1081 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1082 $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1083 return $subscription_service->is_current_user_subscribed();
1084 }
1085
1086 /**
1087 * Render a tier description (stored as markdown text) to safe HTML.
1088 *
1089 * Uses Jetpack's markdown parser, restores paragraph structure (the parser
1090 * strips <p> tags expecting wpautop to run later), forces links to open in a
1091 * new tab (descriptions are shown inside the subscribe modal's iframe), and
1092 * finally sanitizes the output to a small tag allowlist.
1093 *
1094 * @param mixed $description Raw tier description (markdown text). Non-scalar
1095 * values are treated as empty.
1096 * @return string Sanitized HTML, or an empty string for an empty description.
1097 */
1098 public static function render_tier_description_html( $description ) {
1099 if ( ! is_scalar( $description ) ) {
1100 return '';
1101 }
1102 $description = (string) $description;
1103 if ( '' === trim( $description ) ) {
1104 return '';
1105 }
1106
1107 if ( ! class_exists( 'WPCom_Markdown' ) ) {
1108 require_once JETPACK__PLUGIN_DIR . 'modules/markdown/easy-markdown.php';
1109 }
1110
1111 $html = WPCom_Markdown::get_instance()->transform(
1112 $description,
1113 array(
1114 'unslash' => false,
1115 'id' => false,
1116 )
1117 );
1118 $html = wpautop( $html );
1119 $html = links_add_target( $html, '_blank' );
1120
1121 return wp_kses( $html, self::TIER_DESCRIPTION_ALLOWED_HTML );
1122 }
1123 }
1124 Jetpack_Memberships::get_instance();
1125