Ei kuvausta

class-jetpack-memberships.php 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. <?php
  2. /**
  3. * Jetpack_Memberships: wrapper for memberships functions.
  4. *
  5. * @package Jetpack
  6. * @since 7.3.0
  7. */
  8. use Automattic\Jetpack\Blocks;
  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. * These are defaults for wp_kses ran on the membership button.
  40. *
  41. * @var array
  42. */
  43. private static $tags_allowed_in_the_button = array( 'br' => array() );
  44. /**
  45. * The minimum required plan for this Gutenberg block.
  46. *
  47. * @var string Plan slug
  48. */
  49. private static $required_plan;
  50. /**
  51. * Track recurring payments block registration.
  52. *
  53. * @var boolean True if block registration has been executed.
  54. */
  55. private static $has_registered_block = false;
  56. /**
  57. * Classic singleton pattern
  58. *
  59. * @var Jetpack_Memberships
  60. */
  61. private static $instance;
  62. /**
  63. * Currencies we support and Stripe's minimum amount for a transaction in that currency.
  64. *
  65. * @link https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
  66. *
  67. * List has to be in with `SUPPORTED_CURRENCIES` in extensions/shared/currencies.js and
  68. * `Memberships_Product::SUPPORTED_CURRENCIES` in the WP.com memberships library.
  69. */
  70. const SUPPORTED_CURRENCIES = array(
  71. 'USD' => 0.5,
  72. 'AUD' => 0.5,
  73. 'BRL' => 0.5,
  74. 'CAD' => 0.5,
  75. 'CHF' => 0.5,
  76. 'DKK' => 2.5,
  77. 'EUR' => 0.5,
  78. 'GBP' => 0.3,
  79. 'HKD' => 4.0,
  80. 'INR' => 0.5,
  81. 'JPY' => 50,
  82. 'MXN' => 10,
  83. 'NOK' => 3.0,
  84. 'NZD' => 0.5,
  85. 'PLN' => 2.0,
  86. 'SEK' => 3.0,
  87. 'SGD' => 0.5,
  88. );
  89. /**
  90. * Jetpack_Memberships constructor.
  91. */
  92. private function __construct() {}
  93. /**
  94. * The actual constructor initializing the object.
  95. *
  96. * @return Jetpack_Memberships
  97. */
  98. public static function get_instance() {
  99. if ( ! self::$instance ) {
  100. self::$instance = new self();
  101. self::$instance->register_init_hook();
  102. // Yes, `personal-bundle` with a dash, `jetpack_personal` with an underscore. Check the v1.5 endpoint to verify.
  103. self::$required_plan = ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ? 'personal-bundle' : 'jetpack_personal';
  104. }
  105. return self::$instance;
  106. }
  107. /**
  108. * Get the map that defines the shape of CPT post. keys are names of fields and
  109. * 'meta' is the name of actual WP post meta field that corresponds.
  110. *
  111. * @return array
  112. */
  113. private static function get_plan_property_mapping() {
  114. $meta_prefix = 'jetpack_memberships_';
  115. $properties = array(
  116. 'price' => array(
  117. 'meta' => $meta_prefix . 'price',
  118. ),
  119. 'currency' => array(
  120. 'meta' => $meta_prefix . 'currency',
  121. ),
  122. );
  123. return $properties;
  124. }
  125. /**
  126. * Inits further hooks on init hook.
  127. */
  128. private function register_init_hook() {
  129. add_action( 'init', array( $this, 'init_hook_action' ) );
  130. add_action( 'jetpack_register_gutenberg_extensions', array( $this, 'register_gutenberg_block' ) );
  131. }
  132. /**
  133. * Actual hooks initializing on init.
  134. */
  135. public function init_hook_action() {
  136. add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
  137. add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
  138. $this->setup_cpts();
  139. }
  140. /**
  141. * Sets up the custom post types for the module.
  142. */
  143. private function setup_cpts() {
  144. /*
  145. * PLAN data structure.
  146. */
  147. $capabilities = array(
  148. 'edit_post' => 'edit_posts',
  149. 'read_post' => 'read_private_posts',
  150. 'delete_post' => 'delete_posts',
  151. 'edit_posts' => 'edit_posts',
  152. 'edit_others_posts' => 'edit_others_posts',
  153. 'publish_posts' => 'publish_posts',
  154. 'read_private_posts' => 'read_private_posts',
  155. );
  156. $order_args = array(
  157. 'label' => esc_html__( 'Plan', 'jetpack' ),
  158. 'description' => esc_html__( 'Recurring Payments plans', 'jetpack' ),
  159. 'supports' => array( 'title', 'custom-fields', 'content' ),
  160. 'hierarchical' => false,
  161. 'public' => false,
  162. 'show_ui' => false,
  163. 'show_in_menu' => false,
  164. 'show_in_admin_bar' => false,
  165. 'show_in_nav_menus' => false,
  166. 'can_export' => true,
  167. 'has_archive' => false,
  168. 'exclude_from_search' => true,
  169. 'publicly_queryable' => false,
  170. 'rewrite' => false,
  171. 'capabilities' => $capabilities,
  172. 'show_in_rest' => false,
  173. );
  174. register_post_type( self::$post_type_plan, $order_args );
  175. }
  176. /**
  177. * Allows custom post types to be used by REST API.
  178. *
  179. * @param array $post_types - other post types.
  180. *
  181. * @see hook 'rest_api_allowed_post_types'
  182. * @return array
  183. */
  184. public function allow_rest_api_types( $post_types ) {
  185. $post_types[] = self::$post_type_plan;
  186. return $post_types;
  187. }
  188. /**
  189. * Allows custom meta fields to sync.
  190. *
  191. * @param array $post_meta - previously changet post meta.
  192. *
  193. * @return array
  194. */
  195. public function allow_sync_post_meta( $post_meta ) {
  196. $meta_keys = array_map(
  197. array( $this, 'return_meta' ),
  198. $this->get_plan_property_mapping()
  199. );
  200. return array_merge( $post_meta, array_values( $meta_keys ) );
  201. }
  202. /**
  203. * This returns meta attribute of passet array.
  204. * Used for array functions.
  205. *
  206. * @param array $map - stuff.
  207. *
  208. * @return mixed
  209. */
  210. public function return_meta( $map ) {
  211. return $map['meta'];
  212. }
  213. /**
  214. * Renders a preview of the Recurring Payment button, which is not hooked
  215. * up to the subscription url. Used to preview the block on the frontend
  216. * for site editors when Stripe has not been connected.
  217. *
  218. * @param array $attrs - attributes in the shortcode.
  219. * @param string $content - Recurring Payment block content.
  220. *
  221. * @return string|void
  222. */
  223. public function render_button_preview( $attrs, $content = null ) {
  224. if ( ! empty( $content ) ) {
  225. $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
  226. $content = str_replace( 'recurring-payments-id', $block_id, $content );
  227. $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
  228. return $content;
  229. }
  230. return $this->deprecated_render_button_v1( $attrs, null );
  231. }
  232. /**
  233. * Determines whether the button preview should be rendered. Returns true
  234. * if the user has editing permissions, the button is not configured correctly
  235. * (because it requires a plan upgrade or Stripe connection), and the
  236. * button is a child of a Premium Content block.
  237. *
  238. * @param WP_Block $block Recurring Payments block instance.
  239. *
  240. * @return boolean
  241. */
  242. public function should_render_button_preview( $block ) {
  243. $user_can_edit = $this->user_can_edit();
  244. $requires_stripe_connection = ! $this->get_connected_account_id();
  245. $requires_upgrade = ! self::is_supported_jetpack_recurring_payments();
  246. $is_premium_content_child = false;
  247. if ( isset( $block ) && isset( $block->context['isPremiumContentChild'] ) ) {
  248. $is_premium_content_child = (int) $block->context['isPremiumContentChild'];
  249. }
  250. return (
  251. $is_premium_content_child &&
  252. $user_can_edit &&
  253. ( $requires_upgrade || $requires_stripe_connection )
  254. );
  255. }
  256. /**
  257. * Callback that parses the membership purchase shortcode.
  258. *
  259. * @param array $attributes - attributes in the shortcode. `id` here is the CPT id of the plan.
  260. * @param string $content - Recurring Payment block content.
  261. * @param WP_Block $block - Recurring Payment block instance.
  262. *
  263. * @return string|void
  264. */
  265. public function render_button( $attributes, $content = null, $block = null ) {
  266. Jetpack_Gutenberg::load_assets_as_required( self::$button_block_name, array( 'thickbox', 'wp-polyfill' ) );
  267. if ( $this->should_render_button_preview( $block ) ) {
  268. return $this->render_button_preview( $attributes, $content );
  269. }
  270. if ( empty( $attributes['planId'] ) ) {
  271. return;
  272. }
  273. $plan_id = (int) $attributes['planId'];
  274. $product = get_post( $plan_id );
  275. if ( ! $product || is_wp_error( $product ) ) {
  276. return;
  277. }
  278. if ( $product->post_type !== self::$post_type_plan || 'publish' !== $product->post_status ) {
  279. return;
  280. }
  281. add_thickbox();
  282. if ( ! empty( $content ) ) {
  283. $block_id = esc_attr( wp_unique_id( 'recurring-payments-block-' ) );
  284. $content = str_replace( 'recurring-payments-id', $block_id, $content );
  285. $content = str_replace( 'wp-block-jetpack-recurring-payments', 'wp-block-jetpack-recurring-payments wp-block-button', $content );
  286. $subscribe_url = $this->get_subscription_url( $plan_id );
  287. return str_replace( 'href="#"', 'href="' . $subscribe_url . '"', $content );
  288. }
  289. return $this->deprecated_render_button_v1( $attributes, $plan_id );
  290. }
  291. /**
  292. * Builds subscription URL for this membership using the current blog and
  293. * supplied plan IDs.
  294. *
  295. * @param integer $plan_id - Unique ID for the plan being subscribed to.
  296. * @return string
  297. */
  298. public function get_subscription_url( $plan_id ) {
  299. global $wp;
  300. return add_query_arg(
  301. array(
  302. 'blog' => esc_attr( self::get_blog_id() ),
  303. 'plan' => esc_attr( $plan_id ),
  304. 'lang' => esc_attr( get_locale() ),
  305. 'pid' => esc_attr( get_the_ID() ), // Needed for analytics purposes.
  306. 'redirect' => esc_attr( rawurlencode( home_url( $wp->request ) ) ), // Needed for redirect back in case of redirect-based flow.
  307. ),
  308. 'https://subscribe.wordpress.com/memberships/'
  309. );
  310. }
  311. /**
  312. * Renders a deprecated legacy version of the button HTML.
  313. *
  314. * @param array $attrs - Array containing the Recurring Payment block attributes.
  315. * @param integer $plan_id - Unique plan ID the membership is for.
  316. *
  317. * @return string
  318. */
  319. public function deprecated_render_button_v1( $attrs, $plan_id ) {
  320. $button_label = isset( $attrs['submitButtonText'] )
  321. ? $attrs['submitButtonText']
  322. : __( 'Your contribution', 'jetpack' );
  323. $button_styles = array();
  324. if ( ! empty( $attrs['customBackgroundButtonColor'] ) ) {
  325. array_push(
  326. $button_styles,
  327. sprintf(
  328. 'background-color: %s',
  329. sanitize_hex_color( $attrs['customBackgroundButtonColor'] )
  330. )
  331. );
  332. }
  333. if ( ! empty( $attrs['customTextButtonColor'] ) ) {
  334. array_push(
  335. $button_styles,
  336. sprintf(
  337. 'color: %s',
  338. sanitize_hex_color( $attrs['customTextButtonColor'] )
  339. )
  340. );
  341. }
  342. $button_styles = implode( ';', $button_styles );
  343. return sprintf(
  344. '<div class="%1$s"><a role="button" %6$s href="%2$s" class="%3$s" style="%4$s">%5$s</a></div>',
  345. esc_attr(
  346. Jetpack_Gutenberg::block_classes(
  347. self::$button_block_name,
  348. $attrs,
  349. array( 'wp-block-button' )
  350. )
  351. ),
  352. esc_url( $this->get_subscription_url( $plan_id ) ),
  353. isset( $attrs['submitButtonClasses'] ) ? esc_attr( $attrs['submitButtonClasses'] ) : 'wp-block-button__link',
  354. esc_attr( $button_styles ),
  355. wp_kses( $button_label, self::$tags_allowed_in_the_button ),
  356. isset( $attrs['submitButtonAttributes'] ) ? sanitize_text_field( $attrs['submitButtonAttributes'] ) : '' // Needed for arbitrary target=_blank on WPCOM VIP.
  357. );
  358. }
  359. /**
  360. * Get current blog id.
  361. *
  362. * @return int
  363. */
  364. public static function get_blog_id() {
  365. if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
  366. return get_current_blog_id();
  367. }
  368. return Jetpack_Options::get_option( 'id' );
  369. }
  370. /**
  371. * Get the id of the connected payment acount (Stripe etc).
  372. *
  373. * @return int|void
  374. */
  375. public static function get_connected_account_id() {
  376. return get_option( self::$connected_account_id_option_name );
  377. }
  378. /**
  379. * Determines whether the current user can edit.
  380. *
  381. * @return bool Whether the user can edit.
  382. */
  383. public static function user_can_edit() {
  384. $user = wp_get_current_user();
  385. // phpcs:ignore ImportDetection.Imports.RequireImports.Symbol
  386. return 0 !== $user->ID && current_user_can( 'edit_post', get_the_ID() );
  387. }
  388. /**
  389. * Whether Recurring Payments are enabled. True if the block
  390. * is supported by the site's plan, or if it is a Jetpack site
  391. * and the feature to enable upgrade nudges is active.
  392. *
  393. * @return bool
  394. */
  395. public static function is_enabled_jetpack_recurring_payments() {
  396. return (
  397. self::is_supported_jetpack_recurring_payments() ||
  398. (
  399. Jetpack::is_connection_ready() &&
  400. /** This filter is documented in class.jetpack-gutenberg.php */
  401. ! apply_filters( 'jetpack_block_editor_enable_upgrade_nudge', false )
  402. )
  403. );
  404. }
  405. /**
  406. * Whether the site's plan supports the Recurring Payments block.
  407. */
  408. public static function is_supported_jetpack_recurring_payments() {
  409. // For WPCOM sites.
  410. if ( defined( 'IS_WPCOM' ) && IS_WPCOM && function_exists( 'has_any_blog_stickers' ) ) {
  411. $site_id = get_current_blog_id();
  412. return has_any_blog_stickers( array( 'personal-plan', 'premium-plan', 'business-plan', 'ecommerce-plan' ), $site_id );
  413. }
  414. // For Jetpack sites.
  415. return (
  416. Jetpack::is_connection_ready() &&
  417. Jetpack_Plan::supports( 'recurring-payments' )
  418. );
  419. }
  420. /**
  421. * Register the Recurring Payments Gutenberg block
  422. */
  423. public function register_gutenberg_block() {
  424. // This gate was introduced to prevent duplicate registration. A race condition exists where
  425. // the registration that happens via extensions/blocks/recurring-payments/recurring-payments.php
  426. // was adding the registration action after the action had been run in some contexts.
  427. if ( self::$has_registered_block ) {
  428. return;
  429. }
  430. if ( self::is_enabled_jetpack_recurring_payments() ) {
  431. $deprecated = function_exists( 'gutenberg_get_post_from_context' );
  432. $uses = $deprecated ? 'context' : 'uses_context';
  433. Blocks::jetpack_register_block(
  434. 'jetpack/recurring-payments',
  435. array(
  436. 'render_callback' => array( $this, 'render_button' ),
  437. $uses => array( 'isPremiumContentChild' ),
  438. )
  439. );
  440. } else {
  441. Jetpack_Gutenberg::set_extension_unavailable(
  442. 'jetpack/recurring-payments',
  443. 'missing_plan',
  444. array(
  445. 'required_feature' => 'memberships',
  446. 'required_plan' => self::$required_plan,
  447. )
  448. );
  449. }
  450. self::$has_registered_block = true;
  451. }
  452. }
  453. Jetpack_Memberships::get_instance();