暂无描述

simple-payments.php 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
  2. /**
  3. * Simple Payments lets users embed a PayPal button fully integrated with wpcom to sell products on the site.
  4. * This is not a proper module yet, because not all the pieces are in place. Until everything is shipped, it can be turned
  5. * into module that can be enabled/disabled.
  6. *
  7. * @package automattic/jetpack
  8. */
  9. /**
  10. * Jetpack_Simple_Payments
  11. */
  12. class Jetpack_Simple_Payments {
  13. // These have to be under 20 chars because that is CPT limit.
  14. static $post_type_order = 'jp_pay_order';
  15. static $post_type_product = 'jp_pay_product';
  16. static $shortcode = 'simple-payment';
  17. static $css_classname_prefix = 'jetpack-simple-payments';
  18. static $required_plan;
  19. // Increase this number each time there's a change in CSS or JS to bust cache.
  20. static $version = '0.25';
  21. // Classic singleton pattern:
  22. private static $instance;
  23. private function __construct() {}
  24. static function getInstance() {
  25. if ( ! self::$instance ) {
  26. self::$instance = new self();
  27. self::$instance->register_init_hooks();
  28. self::$required_plan = ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ? 'value_bundle' : 'jetpack_premium';
  29. }
  30. return self::$instance;
  31. }
  32. private function register_scripts_and_styles() {
  33. /**
  34. * Paypal heavily discourages putting that script in your own server:
  35. * @see https://developer.paypal.com/docs/integration/direct/express-checkout/integration-jsv4/add-paypal-button/
  36. */
  37. wp_register_script( 'paypal-checkout-js', 'https://www.paypalobjects.com/api/checkout.js', array(), null, true );
  38. wp_register_script( 'paypal-express-checkout', plugins_url( '/paypal-express-checkout.js', __FILE__ ),
  39. array( 'jquery', 'paypal-checkout-js' ), self::$version );
  40. wp_register_style( 'jetpack-simple-payments', plugins_url( '/simple-payments.css', __FILE__ ), array( 'dashicons' ) );
  41. }
  42. private function register_init_hooks() {
  43. add_action( 'init', array( $this, 'init_hook_action' ) );
  44. add_action( 'rest_api_init', array( $this, 'register_meta_fields_in_rest_api' ) );
  45. }
  46. private function register_shortcode() {
  47. add_shortcode( self::$shortcode, array( $this, 'parse_shortcode' ) );
  48. }
  49. public function init_hook_action() {
  50. add_filter( 'rest_api_allowed_post_types', array( $this, 'allow_rest_api_types' ) );
  51. add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'allow_sync_post_meta' ) );
  52. if ( ! is_admin() ) {
  53. $this->register_scripts_and_styles();
  54. }
  55. $this->register_shortcode();
  56. $this->setup_cpts();
  57. add_filter( 'the_content', array( $this, 'remove_auto_paragraph_from_product_description' ), 0 );
  58. }
  59. /**
  60. * Enqueue the static assets needed in the frontend.
  61. */
  62. public function enqueue_frontend_assets() {
  63. if ( ! wp_style_is( 'jetpack-simple-payments', 'enqueued' ) ) {
  64. wp_enqueue_style( 'jetpack-simple-payments' );
  65. }
  66. if ( ! wp_script_is( 'paypal-express-checkout', 'enqueued' ) ) {
  67. wp_enqueue_script( 'paypal-express-checkout' );
  68. }
  69. }
  70. /**
  71. * Add an inline script for setting up the PayPal checkout button.
  72. *
  73. * @param int $id Product ID.
  74. * @param int $dom_id ID of the DOM element with the purchase message.
  75. * @param boolean $is_multiple Whether multiple items of the same product can be purchased.
  76. */
  77. public function setup_paypal_checkout_button( $id, $dom_id, $is_multiple ) {
  78. wp_add_inline_script(
  79. 'paypal-express-checkout',
  80. sprintf(
  81. "try{PaypalExpressCheckout.renderButton( '%d', '%d', '%s', '%d' );}catch(e){}",
  82. esc_js( $this->get_blog_id() ),
  83. esc_js( $id ),
  84. esc_js( $dom_id ),
  85. esc_js( $is_multiple )
  86. )
  87. );
  88. }
  89. function remove_auto_paragraph_from_product_description( $content ) {
  90. if ( get_post_type() === self::$post_type_product ) {
  91. remove_filter( 'the_content', 'wpautop' );
  92. }
  93. return $content;
  94. }
  95. function get_blog_id() {
  96. if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
  97. return get_current_blog_id();
  98. }
  99. return Jetpack_Options::get_option( 'id' );
  100. }
  101. /**
  102. * Used to check whether Simple Payments are enabled for given site.
  103. *
  104. * @return bool True if Simple Payments are enabled, false otherwise.
  105. */
  106. function is_enabled_jetpack_simple_payments() {
  107. /**
  108. * Can be used by plugin authors to disable the conflicting output of Simple Payments.
  109. *
  110. * @since 6.3.0
  111. *
  112. * @param bool True if Simple Payments should be disabled, false otherwise.
  113. */
  114. if ( apply_filters( 'jetpack_disable_simple_payments', false ) ) {
  115. return false;
  116. }
  117. // For WPCOM sites
  118. if ( defined( 'IS_WPCOM' ) && IS_WPCOM && function_exists( 'has_any_blog_stickers' ) ) {
  119. $site_id = $this->get_blog_id();
  120. return has_any_blog_stickers( array( 'premium-plan', 'business-plan', 'ecommerce-plan' ), $site_id );
  121. }
  122. // For all Jetpack sites
  123. return Jetpack::is_connection_ready() && Jetpack_Plan::supports( 'simple-payments' );
  124. }
  125. function parse_shortcode( $attrs, $content = false ) {
  126. if ( empty( $attrs['id'] ) ) {
  127. return;
  128. }
  129. $product = get_post( $attrs['id'] );
  130. if ( ! $product || is_wp_error( $product ) ) {
  131. return;
  132. }
  133. if ( $product->post_type !== self::$post_type_product || 'publish' !== $product->post_status ) {
  134. return;
  135. }
  136. // We allow for overriding the presentation labels
  137. $data = shortcode_atts( array(
  138. 'blog_id' => $this->get_blog_id(),
  139. 'dom_id' => uniqid( self::$css_classname_prefix . '-' . $product->ID . '_', true ),
  140. 'class' => self::$css_classname_prefix . '-' . $product->ID,
  141. 'title' => get_the_title( $product ),
  142. 'description' => $product->post_content,
  143. 'cta' => get_post_meta( $product->ID, 'spay_cta', true ),
  144. 'multiple' => get_post_meta( $product->ID, 'spay_multiple', true ) || '0'
  145. ), $attrs );
  146. $data['price'] = $this->format_price(
  147. get_post_meta( $product->ID, 'spay_price', true ),
  148. get_post_meta( $product->ID, 'spay_currency', true )
  149. );
  150. $data['id'] = $attrs['id'];
  151. if ( ! $this->is_enabled_jetpack_simple_payments() ) {
  152. if ( jetpack_is_frontend() ) {
  153. return $this->output_admin_warning( $data );
  154. }
  155. return;
  156. }
  157. $this->enqueue_frontend_assets();
  158. $this->setup_paypal_checkout_button( $attrs['id'], $data['dom_id'], $data['multiple'] );
  159. return $this->output_shortcode( $data );
  160. }
  161. function output_admin_warning( $data ) {
  162. if ( ! current_user_can( 'manage_options' ) ) {
  163. return;
  164. }
  165. jetpack_require_lib( 'components' );
  166. return Jetpack_Components::render_upgrade_nudge( array(
  167. 'plan' => self::$required_plan
  168. ) );
  169. }
  170. /**
  171. * Get the HTML output to use as PayPal purchase box.
  172. *
  173. * @param string $dom_id ID of the DOM element with the purchase message.
  174. * @param boolean $is_multiple Whether multiple items of the same product can be purchased.
  175. *
  176. * @return string
  177. */
  178. public function output_purchase_box( $dom_id, $is_multiple ) {
  179. $items = '';
  180. $css_prefix = self::$css_classname_prefix;
  181. if ( $is_multiple ) {
  182. $items = sprintf( '
  183. <div class="%1$s">
  184. <input class="%2$s" type="number" value="1" min="1" id="%3$s" />
  185. </div>
  186. ',
  187. esc_attr( "${css_prefix}-items" ),
  188. esc_attr( "${css_prefix}-items-number" ),
  189. esc_attr( "{$dom_id}_number" )
  190. );
  191. }
  192. return sprintf(
  193. '<div class="%1$s" id="%2$s"></div><div class="%3$s">%4$s<div class="%5$s" id="%6$s"></div></div>',
  194. esc_attr( "${css_prefix}-purchase-message" ),
  195. esc_attr( "{$dom_id}-message-container" ),
  196. esc_attr( "${css_prefix}-purchase-box" ),
  197. $items,
  198. esc_attr( "${css_prefix}-button" ),
  199. esc_attr( "{$dom_id}_button" )
  200. );
  201. }
  202. /**
  203. * Get the HTML output to replace the `simple-payments` shortcode.
  204. *
  205. * @param array $data Product data.
  206. * @return string
  207. */
  208. public function output_shortcode( $data ) {
  209. $css_prefix = self::$css_classname_prefix;
  210. $image = "";
  211. if( has_post_thumbnail( $data['id'] ) ) {
  212. $image = sprintf( '<div class="%1$s"><div class="%2$s">%3$s</div></div>',
  213. esc_attr( "${css_prefix}-product-image" ),
  214. esc_attr( "${css_prefix}-image" ),
  215. get_the_post_thumbnail( $data['id'], 'full' )
  216. );
  217. }
  218. return sprintf( '
  219. <div class="%1$s">
  220. <div class="%2$s">
  221. %3$s
  222. <div class="%4$s">
  223. <div class="%5$s"><p>%6$s</p></div>
  224. <div class="%7$s"><p>%8$s</p></div>
  225. <div class="%9$s"><p>%10$s</p></div>
  226. %11$s
  227. </div>
  228. </div>
  229. </div>
  230. ',
  231. esc_attr( "{$data['class']} ${css_prefix}-wrapper" ),
  232. esc_attr( "${css_prefix}-product" ),
  233. $image,
  234. esc_attr( "${css_prefix}-details" ),
  235. esc_attr( "${css_prefix}-title" ),
  236. esc_html( $data['title'] ),
  237. esc_attr( "${css_prefix}-description" ),
  238. wp_kses( $data['description'], wp_kses_allowed_html( 'post' ) ),
  239. esc_attr( "${css_prefix}-price" ),
  240. esc_html( $data['price'] ),
  241. $this->output_purchase_box( $data['dom_id'], $data['multiple'] )
  242. );
  243. }
  244. /**
  245. * Format a price with currency
  246. *
  247. * Uses currency-aware formatting to output a formatted price with a simple fallback.
  248. *
  249. * Largely inspired by WordPress.com's Store_Price::display_currency
  250. *
  251. * @param string $price Price.
  252. * @param string $currency Currency.
  253. * @return string Formatted price.
  254. */
  255. private function format_price( $price, $currency ) {
  256. jetpack_require_lib( 'class-jetpack-currencies' );
  257. return Jetpack_Currencies::format_price( $price, $currency );
  258. }
  259. /**
  260. * Allows custom post types to be used by REST API.
  261. * @param $post_types
  262. * @see hook 'rest_api_allowed_post_types'
  263. * @return array
  264. */
  265. function allow_rest_api_types( $post_types ) {
  266. $post_types[] = self::$post_type_order;
  267. $post_types[] = self::$post_type_product;
  268. return $post_types;
  269. }
  270. function allow_sync_post_meta( $post_meta ) {
  271. return array_merge( $post_meta, array(
  272. 'spay_paypal_id',
  273. 'spay_status',
  274. 'spay_product_id',
  275. 'spay_quantity',
  276. 'spay_price',
  277. 'spay_customer_email',
  278. 'spay_currency',
  279. 'spay_cta',
  280. 'spay_email',
  281. 'spay_multiple',
  282. 'spay_formatted_price',
  283. ) );
  284. }
  285. /**
  286. * Enable Simple payments custom meta values for access through the REST API.
  287. * Field’s value will be exposed on a .meta key in the endpoint response,
  288. * and WordPress will handle setting up the callbacks for reading and writing
  289. * to that meta key.
  290. *
  291. * @link https://developer.wordpress.org/rest-api/extending-the-rest-api/modifying-responses/
  292. */
  293. public function register_meta_fields_in_rest_api() {
  294. register_meta( 'post', 'spay_price', array(
  295. 'description' => esc_html__( 'Simple payments; price.', 'jetpack' ),
  296. 'object_subtype' => self::$post_type_product,
  297. 'sanitize_callback' => array( $this, 'sanitize_price' ),
  298. 'show_in_rest' => true,
  299. 'single' => true,
  300. 'type' => 'number',
  301. ) );
  302. register_meta( 'post', 'spay_currency', array(
  303. 'description' => esc_html__( 'Simple payments; currency code.', 'jetpack' ),
  304. 'object_subtype' => self::$post_type_product,
  305. 'sanitize_callback' => array( $this, 'sanitize_currency' ),
  306. 'show_in_rest' => true,
  307. 'single' => true,
  308. 'type' => 'string',
  309. ) );
  310. register_meta( 'post', 'spay_cta', array(
  311. 'description' => esc_html__( 'Simple payments; text with "Buy" or other CTA', 'jetpack' ),
  312. 'object_subtype' => self::$post_type_product,
  313. 'sanitize_callback' => 'sanitize_text_field',
  314. 'show_in_rest' => true,
  315. 'single' => true,
  316. 'type' => 'string',
  317. ) );
  318. register_meta( 'post', 'spay_multiple', array(
  319. 'description' => esc_html__( 'Simple payments; allow multiple items', 'jetpack' ),
  320. 'object_subtype' => self::$post_type_product,
  321. 'sanitize_callback' => 'rest_sanitize_boolean',
  322. 'show_in_rest' => true,
  323. 'single' => true,
  324. 'type' => 'boolean',
  325. ) );
  326. register_meta( 'post', 'spay_email', array(
  327. 'description' => esc_html__( 'Simple payments button; paypal email.', 'jetpack' ),
  328. 'sanitize_callback' => 'sanitize_email',
  329. 'show_in_rest' => true,
  330. 'single' => true,
  331. 'type' => 'string',
  332. ) );
  333. register_meta( 'post', 'spay_status', array(
  334. 'description' => esc_html__( 'Simple payments; status.', 'jetpack' ),
  335. 'object_subtype' => self::$post_type_product,
  336. 'sanitize_callback' => 'sanitize_text_field',
  337. 'show_in_rest' => true,
  338. 'single' => true,
  339. 'type' => 'string',
  340. ) );
  341. }
  342. /**
  343. * Sanitize three-character ISO-4217 Simple payments currency
  344. *
  345. * List has to be in sync with list at the block's client side and widget's backend side:
  346. * @link https://github.com/Automattic/jetpack/blob/31efa189ad223c0eb7ad085ac0650a23facf9ef5/extensions/blocks/simple-payments/constants.js#L9-L39
  347. * @link https://github.com/Automattic/jetpack/blob/31efa189ad223c0eb7ad085ac0650a23facf9ef5/modules/widgets/simple-payments.php#L19-L44
  348. *
  349. * Currencies should be supported by PayPal:
  350. * @link https://developer.paypal.com/docs/api/reference/currency-codes/
  351. *
  352. * Indian Rupee (INR) not supported because at the time of the creation of this file
  353. * because it's limited to in-country PayPal India accounts only.
  354. * Discussion: https://github.com/Automattic/wp-calypso/pull/28236
  355. */
  356. public static function sanitize_currency( $currency ) {
  357. $valid_currencies = array(
  358. 'USD',
  359. 'EUR',
  360. 'AUD',
  361. 'BRL',
  362. 'CAD',
  363. 'CZK',
  364. 'DKK',
  365. 'HKD',
  366. 'HUF',
  367. 'ILS',
  368. 'JPY',
  369. 'MYR',
  370. 'MXN',
  371. 'TWD',
  372. 'NZD',
  373. 'NOK',
  374. 'PHP',
  375. 'PLN',
  376. 'GBP',
  377. 'RUB',
  378. 'SGD',
  379. 'SEK',
  380. 'CHF',
  381. 'THB',
  382. );
  383. return in_array( $currency, $valid_currencies ) ? $currency : false;
  384. }
  385. /**
  386. * Sanitize price:
  387. *
  388. * Positive integers and floats
  389. * Supports two decimal places.
  390. * Maximum length: 10.
  391. *
  392. * See `price` from PayPal docs:
  393. * @link https://developer.paypal.com/docs/api/orders/v1/#definition-item
  394. *
  395. * @param $value
  396. * @return null|string
  397. */
  398. public static function sanitize_price( $price ) {
  399. return preg_match( '/^[0-9]{0,10}(\.[0-9]{0,2})?$/', $price ) ? $price : false;
  400. }
  401. /**
  402. * Sets up the custom post types for the module.
  403. */
  404. function setup_cpts() {
  405. /*
  406. * ORDER data structure. holds:
  407. * title = customer_name | 4xproduct_name
  408. * excerpt = customer_name + customer contact info + customer notes from paypal form
  409. * metadata:
  410. * spay_paypal_id - paypal id of transaction
  411. * spay_status
  412. * spay_product_id - post_id of bought product
  413. * spay_quantity - quantity of product
  414. * spay_price - item price at the time of purchase
  415. * spay_customer_email - customer email
  416. * ... (WIP)
  417. */
  418. $order_capabilities = array(
  419. 'edit_post' => 'edit_posts',
  420. 'read_post' => 'read_private_posts',
  421. 'delete_post' => 'delete_posts',
  422. 'edit_posts' => 'edit_posts',
  423. 'edit_others_posts' => 'edit_others_posts',
  424. 'publish_posts' => 'publish_posts',
  425. 'read_private_posts' => 'read_private_posts',
  426. );
  427. $order_args = array(
  428. 'label' => esc_html_x( 'Order', 'noun: a quantity of goods or items purchased or sold', 'jetpack' ),
  429. 'description' => esc_html__( 'Simple Payments orders', 'jetpack' ),
  430. 'supports' => array( 'custom-fields', 'excerpt' ),
  431. 'hierarchical' => false,
  432. 'public' => false,
  433. 'show_ui' => false,
  434. 'show_in_menu' => false,
  435. 'show_in_admin_bar' => false,
  436. 'show_in_nav_menus' => false,
  437. 'can_export' => true,
  438. 'has_archive' => false,
  439. 'exclude_from_search' => true,
  440. 'publicly_queryable' => false,
  441. 'rewrite' => false,
  442. 'capabilities' => $order_capabilities,
  443. 'show_in_rest' => true,
  444. );
  445. register_post_type( self::$post_type_order, $order_args );
  446. /*
  447. * PRODUCT data structure. Holds:
  448. * title - title
  449. * content - description
  450. * thumbnail - image
  451. * metadata:
  452. * spay_price - price
  453. * spay_formatted_price
  454. * spay_currency - currency code
  455. * spay_cta - text with "Buy" or other CTA
  456. * spay_email - paypal email
  457. * spay_multiple - allow for multiple items
  458. * spay_status - status. { enabled | disabled }
  459. */
  460. $product_capabilities = array(
  461. 'edit_post' => 'edit_posts',
  462. 'read_post' => 'read_private_posts',
  463. 'delete_post' => 'delete_posts',
  464. 'edit_posts' => 'publish_posts',
  465. 'edit_others_posts' => 'edit_others_posts',
  466. 'publish_posts' => 'publish_posts',
  467. 'read_private_posts' => 'read_private_posts',
  468. );
  469. $product_args = array(
  470. 'label' => esc_html__( 'Product', 'jetpack' ),
  471. 'description' => esc_html__( 'Simple Payments products', 'jetpack' ),
  472. 'supports' => array( 'title', 'editor','thumbnail', 'custom-fields', 'author' ),
  473. 'hierarchical' => false,
  474. 'public' => false,
  475. 'show_ui' => false,
  476. 'show_in_menu' => false,
  477. 'show_in_admin_bar' => false,
  478. 'show_in_nav_menus' => false,
  479. 'can_export' => true,
  480. 'has_archive' => false,
  481. 'exclude_from_search' => true,
  482. 'publicly_queryable' => false,
  483. 'rewrite' => false,
  484. 'capabilities' => $product_capabilities,
  485. 'show_in_rest' => true,
  486. );
  487. register_post_type( self::$post_type_product, $product_args );
  488. }
  489. /**
  490. * Format a price for display
  491. *
  492. * Largely taken from WordPress.com Store_Price class
  493. *
  494. * The currency array will have the shape:
  495. * format => string sprintf format with placeholders `%1$s`: Symbol `%2$s`: Price.
  496. * symbol => string Symbol string
  497. * desc => string Text description of currency
  498. * decimal => int Number of decimal places
  499. *
  500. * @param string $the_currency The desired currency, e.g. 'USD'.
  501. * @return ?array Currency object or null if not found.
  502. */
  503. private static function get_currency( $the_currency ) {
  504. jetpack_require_lib( 'class-jetpack-currencies' );
  505. $currencies = Jetpack_Currencies::CURRENCIES;
  506. if ( isset( $currencies[ $the_currency ] ) ) {
  507. return $currencies[ $the_currency ];
  508. }
  509. return null;
  510. }
  511. }
  512. Jetpack_Simple_Payments::getInstance();