PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / trunk
Jetpack – WP Security, Backup, Speed, & Growth vtrunk
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 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 3 weeks ago
class-jetpack-memberships.php
1128 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 'MYR' => 2.00,
198 );
199
200 /**
201 * Jetpack_Memberships constructor.
202 */
203 private function __construct() {}
204
205 /**
206 * The actual constructor initializing the object.
207 *
208 * @return Jetpack_Memberships
209 */
210 public static function get_instance() {
211 if ( ! self::$instance ) {
212 self::$instance = new self();
213 self::$instance->register_init_hook();
214 // Yes, `pro-plan` with a dash, `jetpack_personal` with an underscore. Check the v1.5 endpoint to verify.
215 $wpcom_plan_slug = defined( 'ENABLE_PRO_PLAN' ) ? 'pro-plan' : 'personal-bundle';
216 self::$required_plan = ( new Host() )->is_wpcom_simple() ? $wpcom_plan_slug : 'jetpack_personal';
217 }
218
219 return self::$instance;
220 }
221 /**
222 * Get the map that defines the shape of CPT post. keys are names of fields and
223 * 'meta' is the name of actual WP post meta field that corresponds.
224 *
225 * @return array
226 */
227 private static function get_plan_property_mapping() {
228 $meta_prefix = 'jetpack_memberships_';
229 $properties = array(
230 'price' => array(
231 'meta' => $meta_prefix . 'price',
232 ),
233 'currency' => array(
234 'meta' => $meta_prefix . 'currency',
235 ),
236 'site_subscriber' => array(
237 'meta' => $meta_prefix . 'site_subscriber',
238 ),
239 'product_id' => array(
240 'meta' => $meta_prefix . 'product_id',
241 ),
242 'tier' => array(
243 'meta' => $meta_prefix . 'tier',
244 ),
245 'is_deleted' => array(
246 'meta' => $meta_prefix . 'is_deleted',
247 ),
248 'is_sandboxed' => array(
249 'meta' => $meta_prefix . 'is_sandboxed',
250 ),
251 );
252 return $properties;
253 }
254
255 /**
256 * Inits further hooks on init hook.
257 */
258 private function register_init_hook() {
259 add_action( 'init', array( $this, 'init_hook_action' ) );
260 add_action( 'jetpack_register_gutenberg_extensions', array( $this, 'register_gutenberg_block' ) );
261 // phpcs:ignore WPCUT.SwitchBlog.SwitchBlog -- wpcom flags **every** use of switch_blog, apparently expecting valid instances to ignore or suppress the sniff.
262 add_action( 'switch_blog', array( $this, 'clear_post_access_level_cache' ) );
263 }
264
265 /**
266 * Actual hooks initializing on init.
267 */
268 public function init_hook_action() {
269 add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
270 add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
271 $this->setup_cpts();
272
273 if ( Jetpack::is_module_active( 'subscriptions' ) && Request::is_frontend() ) {
274 add_action( 'wp_logout', array( $this, 'subscriber_logout' ) );
275 }
276 }
277
278 /**
279 * Logs the subscriber out by clearing out the premium content cookie.
280 */
281 public function subscriber_logout() {
282 if ( ! class_exists( 'Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service' ) ) {
283 return;
284 }
285
286 Abstract_Token_Subscription_Service::clear_token_cookie();
287 }
288
289 /**
290 * Sets up the custom post types for the module.
291 */
292 private function setup_cpts() {
293 /*
294 * PLAN data structure.
295 */
296 $capabilities = array(
297 'edit_post' => 'edit_posts',
298 'read_post' => 'read_private_posts',
299 'delete_post' => 'delete_posts',
300 'edit_posts' => 'edit_posts',
301 'edit_others_posts' => 'edit_others_posts',
302 'publish_posts' => 'publish_posts',
303 'read_private_posts' => 'read_private_posts',
304 );
305 $order_args = array(
306 'label' => esc_html__( 'Plan', 'jetpack' ),
307 'description' => esc_html__( 'Recurring Payments plans', 'jetpack' ),
308 'supports' => array( 'title', 'custom-fields', 'content' ),
309 'hierarchical' => false,
310 'public' => false,
311 'show_ui' => false,
312 'show_in_menu' => false,
313 'show_in_admin_bar' => false,
314 'show_in_nav_menus' => false,
315 'can_export' => true,
316 'has_archive' => false,
317 'exclude_from_search' => true,
318 'publicly_queryable' => false,
319 'rewrite' => false,
320 'capabilities' => $capabilities,
321 'show_in_rest' => false,
322 );
323 register_post_type( self::$post_type_plan, $order_args );
324 $coupon_args = array(
325 'label' => esc_html__( 'Coupon', 'jetpack' ),
326 'description' => esc_html__( 'Memberships coupons', 'jetpack' ),
327 'supports' => array( 'title', 'custom-fields', 'content' ),
328 'hierarchical' => false,
329 'public' => false,
330 'show_ui' => false,
331 'show_in_menu' => false,
332 'show_in_admin_bar' => false,
333 'show_in_nav_menus' => false,
334 'can_export' => true,
335 'has_archive' => false,
336 'exclude_from_search' => true,
337 'publicly_queryable' => false,
338 'rewrite' => false,
339 'capabilities' => $capabilities,
340 'show_in_rest' => false,
341 );
342 register_post_type( self::$post_type_coupon, $coupon_args );
343 }
344
345 /**
346 * Allows custom post types to be used by REST API.
347 *
348 * @param array $post_types - other post types.
349 *
350 * @see hook 'rest_api_allowed_post_types'
351 * @return array
352 */
353 public function allow_rest_api_types( $post_types ) {
354 $post_types[] = self::$post_type_plan;
355 $post_types[] = self::$post_type_coupon;
356
357 return $post_types;
358 }
359
360 /**
361 * Allows custom meta fields to sync.
362 *
363 * @param array $post_meta - previously changet post meta.
364 *
365 * @return array
366 */
367 public function allow_sync_post_meta( $post_meta ) {
368 $meta_keys_plans = array_map(
369 array( $this, 'return_meta' ),
370 self::get_plan_property_mapping()
371 );
372
373 $meta_coupons_prefix = self::$post_type_coupon . '_';
374 $meta_keys_coupons = array(
375 $meta_coupons_prefix . 'coupon_code',
376 $meta_coupons_prefix . 'can_be_combined',
377 $meta_coupons_prefix . 'first_time_purchase_only',
378 $meta_coupons_prefix . 'limit_per_user',
379 $meta_coupons_prefix . 'discount_type',
380 $meta_coupons_prefix . 'discount_value',
381 $meta_coupons_prefix . 'discount_percentage',
382 $meta_coupons_prefix . 'discount_currency',
383 $meta_coupons_prefix . 'start_date',
384 $meta_coupons_prefix . 'end_date',
385 $meta_coupons_prefix . 'plan_ids_allow_list',
386 $meta_coupons_prefix . 'duration',
387 $meta_coupons_prefix . 'email_allow_list',
388 $meta_coupons_prefix . 'is_deleted',
389 $meta_coupons_prefix . 'is_sandboxed',
390 );
391
392 return array_merge(
393 $post_meta,
394 array_values( $meta_keys_plans ),
395 $meta_keys_coupons
396 );
397 }
398
399 /**
400 * This returns meta attribute of passet array.
401 * Used for array functions.
402 *
403 * @param array $map - stuff.
404 *
405 * @return mixed
406 */
407 public function return_meta( $map ) {
408 return $map['meta'];
409 }
410
411 /**
412 * Show an error to the user (or embed a clue in the HTML) when the button does not get rendered properly.
413 *
414 * @param WP_Error $error The error message with error code.
415 * @return string The error message rendered as HTML.
416 */
417 public function render_button_error( $error ) {
418 if ( static::user_can_edit() ) {
419 return '<div><strong>Jetpack Memberships Error: ' . $error->get_error_code() . '</strong><br />' . $error->get_error_message() . '</div>';
420 }
421 return '<div>Sorry! This product is not available for purchase at this time.</div><!-- Jetpack Memberships Error: ' . $error->get_error_code() . ' -->';
422 }
423
424 /**
425 * Renders a preview of the Recurring Payment button, which is not hooked
426 * up to the subscription url. Used to preview the block on the frontend
427 * for site editors when Stripe has not been connected.
428 *
429 * @param array $attrs - attributes in the shortcode.
430 * @param string $content - Recurring Payment block content.
431 *
432 * @return string|void
433 */
434 public function render_button_preview( $attrs, $content = null ) {
435 if ( ! empty( $content ) ) {
436 $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
437 $content = str_replace( 'recurring-payments-id', $block_id, $content );
438 $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
439 return $content;
440 }
441 return $this->deprecated_render_button_v1( $attrs, null );
442 }
443
444 /**
445 * Determines whether the button preview should be rendered. Returns true
446 * if the user has editing permissions, the button is not configured correctly
447 * (because it requires a plan upgrade or Stripe connection), and the
448 * button is a child of a Premium Content block.
449 *
450 * @param WP_Block $block Recurring Payments block instance.
451 *
452 * @return boolean
453 */
454 public function should_render_button_preview( $block ) {
455 $user_can_edit = static::user_can_edit();
456 $requires_stripe_connection = ! static::has_connected_account();
457
458 $jetpack_ready = ! self::is_enabled_jetpack_recurring_payments();
459
460 $is_premium_content_child = false;
461 if ( isset( $block ) && isset( $block->context['isPremiumContentChild'] ) ) {
462 $is_premium_content_child = (int) $block->context['isPremiumContentChild'];
463 }
464
465 return $is_premium_content_child &&
466 $user_can_edit &&
467 $requires_stripe_connection &&
468 $jetpack_ready;
469 }
470
471 /**
472 * Callback that parses the membership purchase shortcode.
473 *
474 * @param array $attributes - attributes in the shortcode. `id` here is the CPT id of the plan.
475 * @param string $content - Recurring Payment block content.
476 * @param WP_Block $block - Recurring Payment block instance.
477 *
478 * @return string|void - HTML for the button, void removes the button.
479 */
480 public function render_button( $attributes, $content = null, $block = null ) {
481 Jetpack_Gutenberg::load_assets_as_required( self::$button_block_name );
482
483 if ( $this->should_render_button_preview( $block ) ) {
484 return $this->render_button_preview( $attributes, $content );
485 }
486
487 if ( empty( $attributes['planId'] ) && empty( $attributes['planIds'] ) ) {
488 return $this->render_button_error( new WP_Error( 'jetpack-memberships-rb-npi', __( 'No plan was configured for this button.', 'jetpack' ) . ' ' . __( 'Edit this post and confirm that an existing payment plan is selected for this block.', 'jetpack' ) ) );
489 }
490
491 // This is string of '+` separated plan ids. Loop through them and
492 // filter out the ones that are not valid.
493 $plan_ids = array();
494 if ( ! empty( $attributes['planIds'] ) ) {
495 $plan_ids = $attributes['planIds'];
496 } elseif ( ! empty( $attributes['planId'] ) ) {
497 $plan_ids = explode( '+', $attributes['planId'] );
498 }
499 $valid_plans = array();
500 foreach ( $plan_ids as $plan_id ) {
501 if ( ! is_numeric( $plan_id ) ) {
502 continue;
503 }
504 $product = get_post( $plan_id );
505 if ( ! $product ) {
506 return $this->render_button_error( new WP_Error( 'jetpack-memberships-rb-npf', __( 'Could not find a plan for this button.', 'jetpack' ) . ' ' . __( 'Edit this post and confirm that the selected payment plan still exists and is available for purchase.', 'jetpack' ) ) );
507 }
508 if ( is_wp_error( $product ) ) {
509 '@phan-var WP_Error $product'; // `get_post` isn't supposed to return a WP_Error, so Phan is confused here. See also https://github.com/phan/phan/issues/3127
510 return $this->render_button_error( new WP_Error( 'jetpack-memberships-rb-npf-we', __( 'Encountered an error when getting the plan associated with this button:', 'jetpack' ) . ' ' . $product->get_error_message() . '. ' . __( ' Edit this post and confirm that the selected payment plan still exists and is available for purchase.', 'jetpack' ) ) );
511 }
512 if ( $product->post_type !== self::$post_type_plan ) {
513 return $this->render_button_error( new WP_Error( 'jetpack-memberships-rb-pnplan', __( 'The payment plan selected is not actually a payment plan.', 'jetpack' ) . ' ' . __( 'Edit this post and confirm that the selected payment plan still exists and is available for purchase.', 'jetpack' ) ) );
514 }
515 if ( 'publish' !== $product->post_status ) {
516 return $this->render_button_error( new WP_Error( 'jetpack-memberships-rb-psnpub', __( 'The selected payment plan is not active.', 'jetpack' ) . ' ' . __( 'Edit this post and confirm that the selected payment plan still exists and is available for purchase.', 'jetpack' ) ) );
517 }
518 $valid_plans[] = $plan_id;
519 }
520
521 // If none are valid, return.
522 // (Returning like this makes the button disappear.)
523 if ( empty( $valid_plans ) ) {
524 return;
525 }
526 $plan_id = implode( '+', $valid_plans );
527
528 if ( ! empty( $content ) ) {
529 $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
530 $content = str_replace( 'recurring-payments-id', $block_id, $content );
531 $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
532 $subscribe_url = $this->get_subscription_url( $plan_id );
533
534 $content = preg_replace( '/(href=".*")/U', 'href="' . $subscribe_url . '"', $content );
535 $content = wp_kses_post( $content );
536
537 return $content;
538 }
539
540 return $this->deprecated_render_button_v1( $attributes, $plan_id );
541 }
542
543 /**
544 * Render email callback.
545 *
546 * @param string $block_content The block content.
547 * @param array $parsed_block The parsed block data.
548 * @param object $rendering_context The email rendering context.
549 *
550 * @return string
551 */
552 public function render_button_email( $block_content, array $parsed_block, $rendering_context ) {
553 // Check for the required renderers.
554 if ( ! function_exists( '\Automattic\Jetpack\Extensions\Button\render_email' ) || ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Button' ) ) {
555 return '';
556 }
557
558 // Get the first inner block, which should be the button block.
559 $button_block = $parsed_block['innerBlocks'][0] ?? array();
560
561 // We should only accept button blocks.
562 if ( empty( $button_block['blockName'] ) || 'jetpack/button' !== $button_block['blockName'] ) {
563 return '';
564 }
565
566 // We need attributes.
567 if ( ! isset( $button_block['attrs'] ) || ! is_array( $button_block['attrs'] ) ) {
568 return '';
569 }
570
571 // If the button block is missing text or url, return empty string.
572 if ( empty( $button_block['attrs']['text'] ) || empty( $button_block['attrs']['url'] ) ) {
573 return '';
574 }
575
576 // Reuse the button block's email rendering method.
577 return \Automattic\Jetpack\Extensions\Button\render_email( $block_content, $button_block, $rendering_context );
578 }
579
580 /**
581 * Builds subscription URL for this membership using the current blog and
582 * supplied plan IDs.
583 *
584 * @param integer $plan_id - Unique ID for the plan being subscribed to.
585 * @return string
586 */
587 public function get_subscription_url( $plan_id ) {
588 global $wp;
589
590 return add_query_arg(
591 array(
592 'blog' => esc_attr( self::get_blog_id() ),
593 'plan' => esc_attr( $plan_id ),
594 'lang' => esc_attr( get_locale() ),
595 'pid' => esc_attr( get_the_ID() ), // Needed for analytics purposes.
596 'redirect' => esc_attr( rawurlencode( home_url( $wp->request ) ) ), // Needed for redirect back in case of redirect-based flow.
597 ),
598 'https://subscribe.wordpress.com/memberships/'
599 );
600 }
601
602 /**
603 * Renders a deprecated legacy version of the button HTML.
604 *
605 * @param array $attrs - Array containing the Recurring Payment block attributes.
606 * @param integer $plan_id - Unique plan ID the membership is for.
607 *
608 * @return string
609 */
610 public function deprecated_render_button_v1( $attrs, $plan_id ) {
611 $button_label = $attrs['submitButtonText'] ?? __( 'Your contribution', 'jetpack' );
612
613 $button_styles = array();
614 if ( ! empty( $attrs['customBackgroundButtonColor'] ) ) {
615 array_push(
616 $button_styles,
617 sprintf(
618 'background-color: %s',
619 sanitize_hex_color( $attrs['customBackgroundButtonColor'] )
620 )
621 );
622 }
623 if ( ! empty( $attrs['customTextButtonColor'] ) ) {
624 array_push(
625 $button_styles,
626 sprintf(
627 'color: %s',
628 sanitize_hex_color( $attrs['customTextButtonColor'] )
629 )
630 );
631 }
632 $button_styles = implode( ';', $button_styles );
633
634 return sprintf(
635 '<div class="%1$s"><a role="button" href="%2$s" class="%3$s" style="%4$s">%5$s</a></div>',
636 esc_attr(
637 Blocks::classes(
638 self::$button_block_name,
639 $attrs,
640 array( 'wp-block-button' )
641 )
642 ),
643 esc_url( $this->get_subscription_url( $plan_id ) ),
644 isset( $attrs['submitButtonClasses'] ) ? esc_attr( $attrs['submitButtonClasses'] ) : 'wp-block-button__link',
645 esc_attr( $button_styles ),
646 wp_kses( $button_label, self::$tags_allowed_in_the_button )
647 );
648 }
649
650 /**
651 * Get current blog id.
652 *
653 * @return int
654 */
655 public static function get_blog_id() {
656 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
657 return get_current_blog_id();
658 }
659
660 return Jetpack_Options::get_option( 'id' );
661 }
662
663 /**
664 * Get the id of the connected payment acount (Stripe etc).
665 *
666 * @return bool
667 */
668 public static function has_connected_account() {
669
670 // This is the primary solution.
671 $has_option = get_option( self::$has_connected_account_option_name, false ) ? true : false;
672 if ( $has_option ) {
673 return true;
674 }
675
676 return false;
677 }
678
679 /**
680 * Get the post access level
681 *
682 * If no ID is provided, the method tries to get it from the global post object.
683 *
684 * @param int|null $post_id The ID of the post. Default is null.
685 *
686 * @return string the actual post access level (see projects/plugins/jetpack/extensions/blocks/subscriptions/constants.js for the values).
687 */
688 public static function get_post_access_level( $post_id = null ) {
689 if ( ! $post_id ) {
690 $post_id = get_the_ID();
691 }
692 if ( ! $post_id ) {
693 return Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
694 }
695
696 $blog_id = get_current_blog_id();
697 $cache_key = $blog_id . '_' . $post_id;
698
699 if ( isset( self::$post_access_level_cache[ $cache_key ] ) ) {
700 return self::$post_access_level_cache[ $cache_key ];
701 }
702
703 $post_access_level = get_post_meta( $post_id, self::$post_access_level_meta_name, true );
704 // Defaults to "everybody" when unset, and also when the stored value is not a
705 // string. Corrupt rows (e.g. a serialized array like a:1:{i:0;s:0:"";}) can be
706 // persisted by non-REST write paths, and an array flows unchanged into the
707 // strict string-typed `earn_user_has_access` callback on WPCOM, fataling the
708 // render. Coercing here keeps this canonical accessor's documented string
709 // contract regardless of how the meta was written.
710 if ( empty( $post_access_level ) || ! is_string( $post_access_level ) ) {
711 $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY;
712 }
713
714 self::$post_access_level_cache[ $cache_key ] = $post_access_level;
715
716 return $post_access_level;
717 }
718
719 /**
720 * Get the post tier plan
721 *
722 * If no ID is provided, the method tries to get it from the global post object.
723 *
724 * @param int|null $post_id The ID of the post. Default is null.
725 *
726 * @return WP_Post|null the actual post tier.
727 */
728 public static function get_post_tier( $post_id = null ) {
729 if ( ! $post_id ) {
730 $post_id = get_the_ID();
731 }
732
733 if ( ! $post_id ) {
734 return null;
735 }
736
737 $post_tier_id = get_post_meta( $post_id, self::$post_access_tier_meta_name, true );
738 if ( empty( $post_tier_id ) ) {
739 return null;
740 }
741
742 return get_post( $post_tier_id );
743 }
744
745 /**
746 * Determines whether the current user can edit.
747 *
748 * @return bool Whether the user can edit.
749 */
750 public static function user_can_edit() {
751 $user = wp_get_current_user();
752 return 0 !== $user->ID && current_user_can( 'edit_post', get_the_ID() );
753 }
754
755 /**
756 * Clears the static cache for all users or for a given user.
757 *
758 * @param int|null $user_id The user_id to unset in the cache, otherwise the entire static cache is cleared.
759 * @return void
760 */
761 public static function clear_cache( ?int $user_id = null ) {
762 if ( empty( $user_id ) ) {
763 self::$user_is_paid_subscriber_cache = array();
764 self::$user_can_view_post_cache = array();
765 return;
766 }
767 unset( self::$user_is_paid_subscriber_cache[ $user_id ] );
768 unset( self::$user_can_view_post_cache[ $user_id ] );
769 }
770
771 /**
772 * Determines whether the current user is a paid subscriber and caches the result.
773 *
774 * @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.
775 * @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).
776 * @return bool Whether the post can be viewed
777 */
778 public static function user_is_paid_subscriber( $valid_plan_ids = array(), $user_id = null ) {
779 if ( empty( $user_id ) ) {
780 $user_id = get_current_user_id();
781 if ( empty( $user_id ) ) {
782 return false;
783 }
784 }
785 // sort and stringify sorted valid plan ids to use as a cache key
786 sort( $valid_plan_ids );
787 $cache_key = $user_id . '_' . implode( ',', $valid_plan_ids );
788 if ( ! isset( self::$user_is_paid_subscriber_cache[ $cache_key ] ) ) {
789 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
790 if ( empty( $valid_plan_ids ) ) {
791 $valid_plan_ids = self::get_all_newsletter_plan_ids();
792 }
793 $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service( $user_id );
794 $is_paid_subscriber = $paywall->visitor_can_view_content( $valid_plan_ids, Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS );
795 self::$user_is_paid_subscriber_cache[ $cache_key ] = $is_paid_subscriber;
796 }
797 return self::$user_is_paid_subscriber_cache[ $cache_key ];
798 }
799
800 /**
801 * Determines whether the current user has a pending subscription.
802 *
803 * @return bool Whether the user has a pending subscription
804 */
805 public static function user_is_pending_subscriber() {
806 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
807 $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
808 return $subscription_service->is_current_user_pending_subscriber();
809 }
810
811 /**
812 * Determines whether the current user can view the post based on the newsletter access level
813 * and caches the result.
814 *
815 * @param int|null $post_id Explicit post id to check against.
816 *
817 * @return bool Whether the post can be viewed
818 */
819 public static function user_can_view_post( $post_id = null ) {
820 $user_id = get_current_user_id();
821 if ( null === $post_id ) {
822 $post_id = get_the_ID();
823 }
824
825 if ( false === $post_id ) {
826 $post_id = 0;
827 }
828
829 $cache_key = sprintf( '%d_%d', $user_id, $post_id );
830 if ( isset( self::$user_can_view_post_cache[ $cache_key ] ) ) {
831 return self::$user_can_view_post_cache[ $cache_key ];
832 }
833
834 $post_access_level = self::get_post_access_level( $post_id );
835 if ( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === $post_access_level ) {
836 self::$user_can_view_post_cache[ $cache_key ] = true;
837 return true;
838 }
839
840 // we are sending the post to subscribers so the user is a subscriber
841 if ( defined( 'WPCOM_SENDING_POST_TO_SUBSCRIBERS' ) && WPCOM_SENDING_POST_TO_SUBSCRIBERS && Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS === $post_access_level ) {
842 self::$user_can_view_post_cache[ $cache_key ] = true;
843 return true;
844 }
845
846 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
847 $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
848
849 $all_newsletters_plan_ids = self::get_all_newsletter_plan_ids();
850
851 if ( 0 === count( $all_newsletters_plan_ids ) &&
852 (
853 Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS === $post_access_level ||
854 Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS === $post_access_level
855 )
856 ) {
857 // The post is paywalled but there is no newsletter plans on the site.
858 // We downgrade the post level to subscribers-only
859 $post_access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS;
860 }
861
862 $can_view_post = $paywall->visitor_can_view_content( $all_newsletters_plan_ids, $post_access_level );
863
864 self::$user_can_view_post_cache[ $cache_key ] = $can_view_post;
865 return $can_view_post;
866 }
867
868 /**
869 * Whether Recurring Payments are enabled. True if the block
870 * is supported by the site's plan, or if it is a Jetpack site
871 * and the feature to enable upgrade nudges is active.
872 *
873 * @return bool
874 */
875 public static function is_enabled_jetpack_recurring_payments() {
876 $api_available = ( new Host() )->is_wpcom_simple() || Jetpack::is_connection_ready();
877 return $api_available;
878 }
879
880 /**
881 * Whether to enable the blocks in the editor.
882 * All Monetize blocks (except Simple Payments) need a user with at least `edit_posts` capability
883 *
884 * @return bool
885 */
886 public static function should_enable_monetize_blocks_in_editor() {
887 if ( ! is_admin() ) {
888 // We enable the block for the front-end in all cases
889 return true;
890
891 }
892
893 $is_offline_mode = ( new Status() )->is_offline_mode();
894 $enable_monetize_blocks_in_editor = ( new Host() )->is_wpcom_simple() || ( ! $is_offline_mode );
895 return $enable_monetize_blocks_in_editor;
896 }
897
898 /**
899 * Whether site has any paid plan.
900 *
901 * @param string $type - Type of a plan for which site is configured. For now supports empty and newsletter.
902 *
903 * @return bool
904 */
905 public static function has_configured_plans_jetpack_recurring_payments( $type = '' ) {
906 if ( ! self::is_enabled_jetpack_recurring_payments() ) {
907 return false;
908 }
909 $query = array(
910 'post_type' => self::$post_type_plan,
911 'posts_per_page' => 1,
912 );
913
914 // We want to see if user has any plan marked as a newsletter set up.
915 if ( 'newsletter' === $type ) {
916 $query['meta_key'] = 'jetpack_memberships_site_subscriber';
917 $query['meta_value'] = true;
918 }
919
920 $plans = get_posts( $query );
921 return ( is_countable( $plans ) && count( $plans ) > 0 );
922 }
923
924 /**
925 * Return the list of plan posts
926 *
927 * @return WP_Post[]|WP_Error
928 */
929 public static function get_all_plans() {
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 return get_posts(
938 array(
939 'posts_per_page' => -1,
940 'post_type' => self::$post_type_plan,
941 )
942 );
943 } else {
944 // On cached site on WPCOM
945 require_lib( 'memberships' );
946 return Memberships_Product::get_plans_posts_list( get_current_blog_id() );
947 }
948 }
949
950 /**
951 * Return all membership plans ids (deleted or not)
952 * This function is used both on WPCOM or on Jetpack self-hosted.
953 * Depending on the environment we need to mitigate where the data is retrieved from.
954 *
955 * @param bool $allow_deleted Whether to allow deleted plans to be returned. Defaults to true.
956 *
957 * @return array
958 */
959 public static function get_all_newsletter_plan_ids( $allow_deleted = true ) {
960
961 if ( ! self::is_enabled_jetpack_recurring_payments() ) {
962 return array();
963 }
964
965 // We can retrieve the data directly except on a Jetpack/Atomic cached site or
966 $is_cached_site = ( new Host() )->is_wpcom_simple() && is_jetpack_site();
967 if ( ! $is_cached_site ) {
968 $meta_query = array(
969 array(
970 'key' => 'jetpack_memberships_type',
971 'value' => self::$type_tier,
972 ),
973 );
974
975 if ( $allow_deleted === false ) {
976 $meta_query[] = array(
977 'key' => 'jetpack_memberships_is_deleted',
978 'compare' => 'NOT EXISTS',
979 );
980 }
981
982 return get_posts(
983 array(
984 'posts_per_page' => -1,
985 'fields' => 'ids',
986 'post_type' => self::$post_type_plan,
987 'meta_query' => $meta_query,
988 )
989 );
990
991 } else {
992 // On cached site on WPCOM
993 require_lib( 'memberships' );
994 $list = Memberships_Product::get_product_list( get_current_blog_id(), self::$type_tier, null, $allow_deleted );
995
996 if ( is_wp_error( $list ) ) {
997 return array();
998 }
999
1000 return array_map(
1001 function ( $product ) {
1002 return $product['id'];
1003 }, // Returning only post ids
1004 $list
1005 );
1006 }
1007 }
1008
1009 /**
1010 * Register the Recurring Payments Gutenberg block
1011 */
1012 public function register_gutenberg_block() {
1013 // This gate was introduced to prevent duplicate registration. A race condition exists where
1014 // the registration that happens via extensions/blocks/recurring-payments/recurring-payments.php
1015 // was adding the registration action after the action had been run in some contexts.
1016 if ( self::$has_registered_block ) {
1017 return;
1018 }
1019
1020 if ( self::is_enabled_jetpack_recurring_payments() ) {
1021 Blocks::jetpack_register_block(
1022 'jetpack/recurring-payments',
1023 array(
1024 'render_callback' => array( $this, 'render_button' ),
1025 'render_email_callback' => array( $this, 'render_button_email' ),
1026 'uses_context' => array( 'isPremiumContentChild' ),
1027 'provides_context' => array(
1028 'jetpack/parentBlockWidth' => 'width',
1029 ),
1030 )
1031 );
1032 } else {
1033 Jetpack_Gutenberg::set_extension_unavailable(
1034 'recurring-payments',
1035 'missing_plan',
1036 array(
1037 'required_feature' => 'memberships',
1038 'required_plan' => self::$required_plan,
1039 )
1040 );
1041 }
1042
1043 self::$has_registered_block = true;
1044 }
1045
1046 /**
1047 * Transforms a number into it's short human-readable version.
1048 *
1049 * @param int $subscribers_total The extrapolated excerpt string.
1050 *
1051 * @return string Human-readable version of the number. ie. 1.9 M.
1052 */
1053 public static function get_join_others_text( $subscribers_total ) {
1054 if ( $subscribers_total >= 1000000 ) {
1055 /* translators: %s: number of folks following the blog, millions(M) with one decimal. i.e. 1.1 */
1056 return sprintf( __( 'Join %sM other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000000, 1 ) ) );
1057 }
1058 if ( $subscribers_total >= 10000 ) {
1059 /* translators: %s: number of folks following the blog, thousands(K) with one decimal. i.e. 1.1 */
1060 return sprintf( __( 'Join %sK other subscribers', 'jetpack' ), floatval( number_format_i18n( $subscribers_total / 1000, 1 ) ) );
1061 }
1062
1063 /* translators: %s: number of folks following the blog */
1064 return sprintf( _n( 'Join %s other subscriber', 'Join %s other subscribers', $subscribers_total, 'jetpack' ), number_format_i18n( $subscribers_total ) );
1065 }
1066
1067 /**
1068 * Returns the email of the current user.
1069 *
1070 * @return string
1071 */
1072 public static function get_current_user_email() {
1073 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1074 $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1075 return $subscription_service->get_subscriber_email();
1076 }
1077
1078 /**
1079 * Returns if the current user is subscribed or not.
1080 *
1081 * @return boolean
1082 */
1083 public static function is_current_user_subscribed() {
1084 require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
1085 $subscription_service = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
1086 return $subscription_service->is_current_user_subscribed();
1087 }
1088
1089 /**
1090 * Render a tier description (stored as markdown text) to safe HTML.
1091 *
1092 * Uses Jetpack's markdown parser, restores paragraph structure (the parser
1093 * strips <p> tags expecting wpautop to run later), forces links to open in a
1094 * new tab (descriptions are shown inside the subscribe modal's iframe), and
1095 * finally sanitizes the output to a small tag allowlist.
1096 *
1097 * @param mixed $description Raw tier description (markdown text). Non-scalar
1098 * values are treated as empty.
1099 * @return string Sanitized HTML, or an empty string for an empty description.
1100 */
1101 public static function render_tier_description_html( $description ) {
1102 if ( ! is_scalar( $description ) ) {
1103 return '';
1104 }
1105 $description = (string) $description;
1106 if ( '' === trim( $description ) ) {
1107 return '';
1108 }
1109
1110 if ( ! class_exists( 'WPCom_Markdown' ) ) {
1111 require_once JETPACK__PLUGIN_DIR . 'modules/markdown/easy-markdown.php';
1112 }
1113
1114 $html = WPCom_Markdown::get_instance()->transform(
1115 $description,
1116 array(
1117 'unslash' => false,
1118 'id' => false,
1119 )
1120 );
1121 $html = wpautop( $html );
1122 $html = links_add_target( $html, '_blank' );
1123
1124 return wp_kses( $html, self::TIER_DESCRIPTION_ALLOWED_HTML );
1125 }
1126 }
1127 Jetpack_Memberships::get_instance();
1128