Completed
Push — memberships/paywall-2 ( 99e5da )
by
unknown
59:48 queued 50:49
created

Jetpack_Memberships::get_plan_property_mapping()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 0
dl 0
loc 12
rs 9.8666
c 0
b 0
f 0
1
<?php
2
/**
3
 * Jetpack_Memberships: wrapper for memberships functions.
4
 *
5
 * @package    Jetpack
6
 * @since      7.3.0
7
 */
8
9
/**
10
 * Class Jetpack_Memberships
11
 * This class represents the Memberships functionality.
12
 */
13
class Jetpack_Memberships {
14
	/**
15
	 * CSS class prefix to use in the styling.
16
	 *
17
	 * @var string
18
	 */
19
	public static $css_classname_prefix = 'jetpack-memberships';
20
	/**
21
	 * Our CPT type for the product (plan).
22
	 *
23
	 * @var string
24
	 */
25
	public static $post_type_plan = 'jp_mem_plan';
26
	/**
27
	 * Option that will store currently set up account (Stripe etc) id for memberships.
28
	 *
29
	 * @var string
30
	 */
31
	public static $connected_account_id_option_name = 'jetpack-memberships-connected-account-id';
32
	/**
33
	 * Button block type to use.
34
	 *
35
	 * @var string
36
	 */
37
	private static $button_block_name = 'recurring-payments';
38
	/**
39
	 * The prefix for transients storing cached subscriber statuses.
40
	 *
41
	 * @var string
42
	 */
43
	private static $subscriber_transient_prefix = 'jetpack-payments-subscriber-';
44
	/**
45
	 * Cookie name for subscriber session token.
46
	 * The tokens are identifying WPCOM user_id on WPCOM side.
47
	 *
48
	 * @var string
49
	 */
50
	private static $subscriber_cookie_name = 'jetpack-payments-subscriber-token';
51
	/**
52
	 * Subscriber session token. This value should come from cookie or a redirect.
53
	 *
54
	 * @var string
55
	 */
56
	private $subscriber_token_value = '';
57
	/**
58
	 * Cache for the subscriber data for the current session.
59
	 *
60
	 * @var array
61
	 */
62
	private $subscriber_data = array();
63
	/**
64
	 * Array of post IDs where we don't want to render blocks anymore.
65
	 *
66
	 * @var array
67
	 */
68
	private $stop_render_for_posts = array();
69
	/**
70
	 * These are defaults for wp_kses ran on the membership button.
71
	 *
72
	 * @var array
73
	 */
74
	private static $tags_allowed_in_the_button = array( 'br' => array() );
75
	/**
76
	 * Classic singleton pattern
77
	 *
78
	 * @var Jetpack_Memberships
79
	 */
80
	private static $instance;
81
82
	/**
83
	 * Jetpack_Memberships constructor.
84
	 */
85
	private function __construct() {}
86
87
	/**
88
	 * The actual constructor initializing the object.
89
	 *
90
	 * @return Jetpack_Memberships
91
	 */
92
	public static function get_instance() {
93
		if ( ! self::$instance ) {
94
			self::$instance = new self();
95
			self::$instance->register_init_hook();
96
		}
97
98
		return self::$instance;
99
	}
100
	/**
101
	 * Get the map that defines the shape of CPT post. keys are names of fields and
102
	 * 'meta' is the name of actual WP post meta field that corresponds.
103
	 *
104
	 * @return array
105
	 */
106
	private static function get_plan_property_mapping() {
107
		$meta_prefix = 'jetpack_memberships_';
108
		$properties  = array(
109
			'price'    => array(
110
				'meta' => $meta_prefix . 'price',
111
			),
112
			'currency' => array(
113
				'meta' => $meta_prefix . 'currency',
114
			),
115
		);
116
		return $properties;
117
	}
118
119
	/**
120
	 * Inits further hooks on init hook.
121
	 */
122
	private function register_init_hook() {
123
		add_action( 'init', array( $this, 'init_hook_action' ) );
124
	}
125
126
	/**
127
	 * This reads the user cookie or a redirect value and sets the user session token.
128
	 * User session tokens correspond to a WPCOM user id.
129
	 */
130
	private function setup_session_token() {
131
		global $_GET, $_COOKIE;
132
		// TODO: We need to hook into the various caching plugins as well, to whitelist this cookie.
133
		if ( isset( $_GET[ self::$subscriber_cookie_name ] ) ) {
134
			$this->subscriber_token_value = $_GET[ self::$subscriber_cookie_name ];
135
			setcookie( self::$subscriber_cookie_name, $this->subscriber_token_value, time() + 90 * 24 * 3600, COOKIEPATH, COOKIE_DOMAIN );
136
		} elseif ( isset( $_COOKIE[ self::$subscriber_cookie_name ] ) ) {
137
			$this->subscriber_token_value = $_COOKIE[ self::$subscriber_cookie_name ];
138
		}
139
	}
140
141
	/**
142
	 * Get current subscriber data. Tries to get from cache whenever possible, only in last resort does a WPCOM call to get data.
143
	 * Cache expires every hour per user.
144
	 *
145
	 * @return array
146
	 */
147
	public function get_subscriber_data() {
148
		// If we have stored data that we read previously, we return it.
149
		if ( $this->subscriber_data ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->subscriber_data of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
150
			return $this->subscriber_data;
151
		}
152
		// If we don't know the token of the current customer, return false.
153
		if ( ! $this->subscriber_token_value ) {
154
			return array(
155
				'type'       => 'anon',
156
				'subscribed' => false,
157
			);
158
		}
159
		// If we have this data cached in the transient.
160
		$transient_data = get_transient( self::$subscriber_transient_prefix . $this->subscriber_token_value );
161
		if ( $transient_data ) {
162
			$this->subscriber_data = $transient_data;
163
			return $transient_data;
164
		}
165
		// Ok, looks like we have no data cached on either side. Let us get this data.
166
		$request  = sprintf( '/sites/%s/memberships/reader_token/%s/', Jetpack_Options::get_option( 'id' ), $this->subscriber_token_value );
167
		$response = Jetpack_Client::wpcom_json_api_request_as_blog( $request, '1.1' );
168
		if ( is_wp_error( $response ) ) {
169
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
170
				error_log(
171
					sprintf(
172
						/* translators: 1- error code, 2 - error message */
173
						__( 'We have encountered an error [%1$s] while communicating with WordPress.com servers: %2$s', 'jetpack' ),
174
						$response->get_error_code(),
175
						$response->get_error_message()
176
					)
177
				);
178
			}
179
			$this->subscriber_data = array(
180
				'type'       => 'error',
181
				'subscribed' => false,
182
			);
183
		}
184
		$data = isset( $response['body'] ) ? json_decode( $response['body'], true ) : null;
185
		if ( 200 !== $response['response']['code'] && $data['code'] && $data['message'] ) {
186
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
187
				error_log(
188
					sprintf(
189
						/* translators: 1- error code, 2 - error message */
190
						__( 'We have encountered an error [%1$s] after communicating with WordPress.com servers: %2$s', 'jetpack' ),
191
						$data['code'],
192
						$data['message']
193
					)
194
				);
195
			}
196
			$this->subscriber_data = array(
197
				'type'       => 'error',
198
				'subscribed' => false,
199
			);
200
		} else {
201
			$this->subscriber_data = $data;
0 ignored issues
show
Documentation Bug introduced by
It seems like $data of type * is incompatible with the declared type array of property $subscriber_data.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
202
		}
203
		// We want a transient also in case of an error. We don't want to spam servers in case of errors.
204
		set_transient( self::$subscriber_transient_prefix . $this->subscriber_token_value, $data, time() + 3600 );
205
		return $this->subscriber_data;
206
	}
207
	/**
208
	 * Actual hooks initializing on init.
209
	 */
210
	public function init_hook_action() {
211
		add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
212
		add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
213
		$this->setup_cpts();
214
		$this->setup_session_token();
215
		$this->setup_paywall();
216
	}
217
218
	/**
219
	 * Sets up the custom post types for the module.
220
	 */
221
	private function setup_cpts() {
222
		/*
223
		 * PLAN data structure.
224
		 */
225
		$capabilities = array(
226
			'edit_post'          => 'edit_posts',
227
			'read_post'          => 'read_private_posts',
228
			'delete_post'        => 'delete_posts',
229
			'edit_posts'         => 'edit_posts',
230
			'edit_others_posts'  => 'edit_others_posts',
231
			'publish_posts'      => 'publish_posts',
232
			'read_private_posts' => 'read_private_posts',
233
		);
234
		$order_args   = array(
235
			'label'               => esc_html__( 'Plan', 'jetpack' ),
236
			'description'         => esc_html__( 'Recurring Payments plans', 'jetpack' ),
237
			'supports'            => array( 'title', 'custom-fields', 'content' ),
238
			'hierarchical'        => false,
239
			'public'              => false,
240
			'show_ui'             => false,
241
			'show_in_menu'        => false,
242
			'show_in_admin_bar'   => false,
243
			'show_in_nav_menus'   => false,
244
			'can_export'          => true,
245
			'has_archive'         => false,
246
			'exclude_from_search' => true,
247
			'publicly_queryable'  => false,
248
			'rewrite'             => false,
249
			'capabilities'        => $capabilities,
250
			'show_in_rest'        => false,
251
		);
252
		register_post_type( self::$post_type_plan, $order_args );
253
	}
254
255
	/**
256
	 * Allows custom post types to be used by REST API.
257
	 *
258
	 * @param array $post_types - other post types.
259
	 *
260
	 * @see hook 'rest_api_allowed_post_types'
261
	 * @return array
262
	 */
263
	public function allow_rest_api_types( $post_types ) {
264
		$post_types[] = self::$post_type_plan;
265
266
		return $post_types;
267
	}
268
269
	/**
270
	 * Allows custom meta fields to sync.
271
	 *
272
	 * @param array $post_meta - previously changet post meta.
273
	 *
274
	 * @return array
275
	 */
276
	public function allow_sync_post_meta( $post_meta ) {
277
		$meta_keys = array_map(
278
			array( $this, 'return_meta' ),
279
			$this->get_plan_property_mapping()
280
		);
281
		return array_merge( $post_meta, array_values( $meta_keys ) );
282
	}
283
284
	/**
285
	 * This returns meta attribute of passet array.
286
	 * Used for array functions.
287
	 *
288
	 * @param array $map - stuff.
289
	 *
290
	 * @return mixed
291
	 */
292
	public function return_meta( $map ) {
293
		return $map['meta'];
294
	}
295
296
	/**
297
	 * Hooks into the 'render_block' filter in order to block content access.
298
	 */
299
	private function setup_paywall() {
300
		add_filter( 'render_block', array( $this, 'do_paywall_for_block' ), 10, 2 );
301
	}
302
303
	/**
304
	 * This is hooked into `render_block` filter.
305
	 * The purpose is to not render any blocks following the paywall block.
306
	 * This is achieved by storing the list of post_IDs where rendering of the blocks has been turned off.
307
	 * If we are trying to render a block in one of these posts, we return empty string.
308
	 *
309
	 * @param string $block_content - rendered block.
310
	 * @param array  $block - block metadata.
311
	 *
312
	 * @return string
313
	 */
314
	public function do_paywall_for_block( $block_content, $block ) {
315
		global $post;
316
		// Not in post context.
317
		if ( ! $post ) {
318
			return $block_content;
319
		}
320
		// This block itself is immune. This is checked before block is rendered, so it would not render the block itself.
321
		if ( 'jetpack/' . self::$button_block_name === $block['blockName'] ) {
322
			return $block_content;
323
		}
324
		// This will intercept rendering of any block after this one.
325
		if ( in_array( $post->ID, $this->stop_render_for_posts ) ) {
326
			return '';
327
		}
328
		return $block_content;
329
	}
330
331
	/**
332
	 * Marks the rest of the current post as Paywalled. This will stop rendering any further blocks on this post.
333
	 *
334
	 * @see $this::do_paywall_for_block.
335
	 */
336
	private function paywall_the_post() {
337
		global $post;
338
		$this->stop_render_for_posts[] = $post->ID;
339
	}
340
	/**
341
	 * Callback that parses the membership purchase shortcode.
342
	 *
343
	 * @param array $attrs - attributes in the shortcode. `id` here is the CPT id of the plan.
344
	 *
345
	 * @return string
346
	 */
347
	public function render_button( $attrs ) {
348
		Jetpack_Gutenberg::load_assets_as_required( self::$button_block_name, array( 'thickbox', 'wp-polyfill' ) );
349
350
		if ( empty( $attrs['planId'] ) ) {
351
			return;
352
		}
353
		$id      = intval( $attrs['planId'] );
354
		$product = get_post( $id );
355
		if ( ! $product || is_wp_error( $product ) ) {
356
			return;
357
		}
358
		if ( $product->post_type !== self::$post_type_plan || 'publish' !== $product->post_status ) {
359
			return;
360
		}
361
362
		$data = array(
363
			'blog_id'      => self::get_blog_id(),
364
			'id'           => $id,
365
			'button_label' => __( 'Your contribution', 'jetpack' ),
366
			'powered_text' => __( 'Powered by WordPress.com', 'jetpack' ),
367
		);
368
369
		if ( ! $attrs['paywall'] ) {
370
			return $this->get_purchase_button( $attrs, $data );
371
		}
372
		$subscriber_data = $this->get_subscriber_data();
373
		// User is logged in.
374
		// TODO: some more fallback.
375
		if ( 'anon' !== $subscriber_data['type'] ) {
376
			return '';
377
		}
378
		// We know the user is anonymous.
379
		$this->paywall_the_post();
380
		$login_link = $this->get_login_link( $data );
381
382
		$purchase_button    = $this->get_purchase_button( $attrs, $data );
383
		$subscriber_message = '';
384
		if ( isset( $attrs['subscriberMessage'] ) ) {
385
			$subscriber_message = $attrs['subscriberMessage'];
386
		}
387
		$classes = array(
388
			self::$css_classname_prefix . '-subscriber-message',
389
			self::$css_classname_prefix . '-' . $data['id'] . '-subscriber-message',
390
		);
391
		// TODO: This needs better customization.
392
		return "<div>{$purchase_button}<br/>{$login_link}</div>" . sprintf( '<div class="%s">%s</div>', esc_html( $classes ), $subscriber_message );
393
	}
394
395
	/**
396
	 * Get login URL for WPCOM login flow.
397
	 *
398
	 * @param array $data - Plan data.
399
	 *
400
	 * @return string
401
	 */
402
	private function get_login_link( $data ) {
403
		$current_url = urlencode( ( isset( $_SERVER['HTTPS'] ) ? 'https' : 'http' ) . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]" );
404
		return "<a href='https://subscribe.wordpress.com/status/?blog={$data['blog_id']}&redirect_url={$current_url}'>LOG IN</a></div>";
405
	}
406
407
	/**
408
	 * Get the HTML for the purchase button.
409
	 *
410
	 * @param array $attrs - block attributes.
411
	 * @param array $data - data for the payment plan.
412
	 *
413
	 * @return string
414
	 */
415
	private function get_purchase_button( $attrs, $data ) {
416
		$classes = array(
417
			'wp-block-button__link',
418
			'components-button',
419
			'is-primary',
420
			'is-button',
421
			'wp-block-jetpack-' . self::$button_block_name,
422
			self::$css_classname_prefix . '-' . $data['id'],
423
		);
424
		if ( isset( $attrs['className'] ) ) {
425
			array_push( $classes, $attrs['className'] );
426
		}
427
		if ( isset( $attrs['submitButtonText'] ) ) {
428
			$data['button_label'] = $attrs['submitButtonText'];
429
		}
430
		$button_styles = array();
431
		if ( ! empty( $attrs['customBackgroundButtonColor'] ) ) {
432
			array_push(
433
				$button_styles,
434
				sprintf(
435
					'background-color: %s',
436
					sanitize_hex_color( $attrs['customBackgroundButtonColor'] )
437
				)
438
			);
439
		}
440
		if ( ! empty( $attrs['customTextButtonColor'] ) ) {
441
			array_push(
442
				$button_styles,
443
				sprintf(
444
					'color: %s',
445
					sanitize_hex_color( $attrs['customTextButtonColor'] )
446
				)
447
			);
448
		}
449
		$button_styles = implode( $button_styles, ';' );
450
		add_thickbox();
451
452
		return sprintf(
453
			'<button data-blog-id="%d" data-powered-text="%s" data-plan-id="%d" data-lang="%s" class="%s" style="%s">%s</button>',
454
			esc_attr( $data['blog_id'] ),
455
			esc_attr( $data['powered_text'] ),
456
			esc_attr( $data['id'] ),
457
			esc_attr( get_locale() ),
458
			esc_attr( implode( $classes, ' ' ) ),
459
			esc_attr( $button_styles ),
460
			wp_kses( $data['button_label'], self::$tags_allowed_in_the_button )
461
		);
462
	}
463
464
465
	/**
466
	 * Get current blog id.
467
	 *
468
	 * @return int
469
	 */
470
	public static function get_blog_id() {
471
		if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
472
			return get_current_blog_id();
473
		}
474
475
		return Jetpack_Options::get_option( 'id' );
476
	}
477
478
	/**
479
	 * Get the id of the connected payment acount (Stripe etc).
480
	 *
481
	 * @return int|void
482
	 */
483
	public static function get_connected_account_id() {
484
		return get_option( self::$connected_account_id_option_name );
485
	}
486
}
487
Jetpack_Memberships::get_instance();
488