Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.74% covered (warning)
51.74%
89 / 172
60.00% covered (warning)
60.00%
9 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Products
51.74% covered (warning)
51.74%
89 / 172
60.00% covered (warning)
60.00%
9 / 15
229.89
0.00% covered (danger)
0.00%
0 / 1
 get_bundle_offer
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 get_products_classes
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
5
 register_product_endpoints
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_not_shown_products
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_products
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 get_products_api_data
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 get_products_by_ownership
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 get_all_plugin_filenames
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 get_product
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_product_class
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_products_slugs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_product_data_schema
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
2
 extend_plugins_action_links
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 get_interstitials_state
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 update_interstitials_state
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Class for manipulating products
4 *
5 * @package automattic/my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack;
9
10/**
11 * A class for everything related to product handling in My Jetpack
12 */
13class Products {
14    /**
15     * Constants for the status of a product on a site
16     *
17     * @var string
18     */
19    public const STATUS_SITE_CONNECTION_ERROR       = 'site_connection_error';
20    public const STATUS_USER_CONNECTION_ERROR       = 'user_connection_error';
21    public const STATUS_ACTIVE                      = 'active';
22    public const STATUS_CAN_UPGRADE                 = 'can_upgrade';
23    public const STATUS_EXPIRING_SOON               = 'expiring';
24    public const STATUS_EXPIRED                     = 'expired';
25    public const STATUS_INACTIVE                    = 'inactive';
26    public const STATUS_MODULE_DISABLED             = 'module_disabled';
27    public const STATUS_PLUGIN_ABSENT               = 'plugin_absent';
28    public const STATUS_PLUGIN_ABSENT_WITH_PLAN     = 'plugin_absent_with_plan';
29    public const STATUS_NEEDS_PLAN                  = 'needs_plan';
30    public const STATUS_NEEDS_ACTIVATION            = 'needs_activation';
31    public const STATUS_NEEDS_FIRST_SITE_CONNECTION = 'needs_first_site_connection';
32    public const STATUS_NEEDS_ATTENTION__WARNING    = 'needs_attention_warning';
33    public const STATUS_NEEDS_ATTENTION__ERROR      = 'needs_attention_error';
34
35    public const INTERSTITIALS_OPTION_NAME = 'my_jetpack_products_interstitials_state';
36
37    /**
38     * Keep an existing offer until retired, then choose an eligible replacement.
39     *
40     * @since $$next-version$$
41     * @param string $slug Existing bundle slug.
42     * @return array|null Purchase destination, or null when no replacement is available.
43     */
44    public static function get_bundle_offer( $slug ) {
45        foreach ( array_unique( array( $slug, 'pro', 'complete' ) ) as $candidate ) {
46            $class   = self::get_product_class( $candidate );
47            $pricing = $class ? $class::get_pricing_for_ui() : array();
48            if ( true === ( $pricing['available'] ?? false ) ) {
49                return array(
50                    'slug'               => $candidate,
51                    'title'              => $class::get_title(),
52                    'wpcom_product_slug' => $pricing['wpcom_product_slug'] ?? $class::get_wpcom_product_slug(),
53                );
54            }
55        }
56        return null;
57    }
58
59    /**
60     * List of statuses that display the module as disabled
61     * This is defined as the statuses in which the user willingly has the module disabled whether it be by
62     * default, uninstalling the plugin, disabling the module, or not renewing their plan.
63     *
64     * @var array
65     */
66    public static $disabled_module_statuses = array(
67        self::STATUS_INACTIVE,
68        self::STATUS_MODULE_DISABLED,
69        self::STATUS_PLUGIN_ABSENT,
70        self::STATUS_PLUGIN_ABSENT_WITH_PLAN,
71        self::STATUS_NEEDS_ACTIVATION,
72        self::STATUS_NEEDS_FIRST_SITE_CONNECTION,
73    );
74
75    /**
76     * List of statuses that display the module as broken
77     *
78     * @var array
79     */
80    public static $broken_module_statuses = array(
81        self::STATUS_SITE_CONNECTION_ERROR,
82        self::STATUS_USER_CONNECTION_ERROR,
83    );
84
85    /**
86     * List of statuses that display the module as needing attention with a warning
87     *
88     * @var array
89     */
90    public static $warning_module_statuses = array(
91        self::STATUS_SITE_CONNECTION_ERROR,
92        self::STATUS_USER_CONNECTION_ERROR,
93        self::STATUS_PLUGIN_ABSENT_WITH_PLAN,
94        self::STATUS_NEEDS_PLAN,
95        self::STATUS_NEEDS_ATTENTION__ERROR,
96        self::STATUS_NEEDS_ATTENTION__WARNING,
97    );
98
99    /**
100     * List of statuses that display the module as active
101     *
102     * @var array
103     */
104    public static $active_module_statuses = array(
105        self::STATUS_ACTIVE,
106        self::STATUS_CAN_UPGRADE,
107    );
108
109    /**
110     * List of statuses that display the module as active
111     *
112     * @var array
113     */
114    public static $expiring_or_expired_module_statuses = array(
115        self::STATUS_EXPIRING_SOON,
116        self::STATUS_EXPIRED,
117    );
118
119    /**
120     * List of all statuses that a product can have
121     *
122     * @var array
123     */
124    public static $all_statuses = array(
125        self::STATUS_SITE_CONNECTION_ERROR,
126        self::STATUS_USER_CONNECTION_ERROR,
127        self::STATUS_ACTIVE,
128        self::STATUS_CAN_UPGRADE,
129        self::STATUS_EXPIRING_SOON,
130        self::STATUS_EXPIRED,
131        self::STATUS_INACTIVE,
132        self::STATUS_MODULE_DISABLED,
133        self::STATUS_PLUGIN_ABSENT,
134        self::STATUS_PLUGIN_ABSENT_WITH_PLAN,
135        self::STATUS_NEEDS_PLAN,
136        self::STATUS_NEEDS_ACTIVATION,
137        self::STATUS_NEEDS_FIRST_SITE_CONNECTION,
138        self::STATUS_NEEDS_ATTENTION__WARNING,
139        self::STATUS_NEEDS_ATTENTION__ERROR,
140    );
141
142    /**
143     * Get the list of Products classes
144     *
145     * Here's where all the existing Products are registered
146     *
147     * @throws \Exception If the result of a filter has invalid classes.
148     * @return array List of class names
149     */
150    public static function get_products_classes() {
151        $classes = array(
152            'anti-spam'        => Products\Anti_Spam::class,
153            'backup'           => Products\Backup::class,
154            'boost'            => Products\Boost::class,
155            'crm'              => Products\Crm::class,
156            'creator'          => Products\Creator::class,
157            'extras'           => Products\Extras::class,
158            'jetpack-ai'       => Products\Jetpack_Ai::class,
159            // TODO: Remove this duplicate class ('ai')? See: https://github.com/Automattic/jetpack/pull/35910#pullrequestreview-2456462227.
160            'ai'               => Products\Jetpack_Ai::class,
161            'scan'             => Products\Scan::class,
162            'search'           => Products\Search::class,
163            'social'           => Products\Social::class,
164            'security'         => Products\Security::class,
165            'protect'          => Products\Protect::class,
166            'videopress'       => Products\Videopress::class,
167            'stats'            => Products\Stats::class,
168            'growth'           => Products\Growth::class,
169            'complete'         => Products\Complete::class,
170            'pro'              => Products\Pro::class,
171            // Features.
172            'activity-log'     => Products\Activity_Log::class,
173            'newsletter'       => Products\Newsletter::class,
174            'site-accelerator' => Products\Site_Accelerator::class,
175            'related-posts'    => Products\Related_Posts::class,
176            'jetpack-forms'    => Products\Jetpack_Forms::class,
177        );
178
179        /**
180         * This filter allows plugin to override the Product class of a given product. The new class must be a child class of the default one declared in My Jetpack
181         *
182         * For example, a stand-alone plugin could overwrite its product class to control specific behavior of the product in the My Jetpack page after it is active without having to commit changes to the My Jetpack package:
183         *
184         * add_filter( 'my_jetpack_products_classes', function( $classes ) {
185         *  $classes['my_plugin'] = 'My_Plugin'; // a class that extends the original one declared in the My Jetpack package.
186         *  return $classes
187         * } );
188         *
189         * @param array $classes An array where the keys are the product slugs and the values are the class names.
190         */
191        $final_classes = apply_filters( 'my_jetpack_products_classes', $classes );
192
193        // Check that the classes are still child of the same original classes.
194        foreach ( (array) $final_classes as $slug => $final_class ) {
195            if ( $final_class === $classes[ $slug ] ) {
196                continue;
197            }
198            if ( ! class_exists( $final_class ) || ! is_subclass_of( $final_class, $classes[ $slug ] ) ) {
199                throw new \Exception( 'You can only overwrite a Product class with a child of the original class.' );
200            }
201        }
202
203        return $final_classes;
204    }
205
206    /**
207     * Register endpoints related to product classes
208     *
209     * @return void
210     */
211    public static function register_product_endpoints() {
212        $classes = self::get_products_classes();
213
214        foreach ( $classes as $class ) {
215            $class::register_endpoints();
216        }
217    }
218
219    /**
220     * List of product slugs that are displayed on the main My Jetpack page
221     *
222     * @var array
223     */
224    public static $shown_products = array(
225        'anti-spam',
226        'backup',
227        'boost',
228        'crm',
229        'jetpack-ai',
230        'search',
231        'social',
232        'protect',
233        'videopress',
234        'stats',
235    );
236
237    /**
238     * Gets the list of product slugs that are Not displayed on the main My Jetpack page
239     *
240     * @return array
241     */
242    public static function get_not_shown_products() {
243        return array_diff( array_keys( static::get_products_classes() ), self::$shown_products );
244    }
245
246    /**
247     * Product data
248     *
249     * @param array $product_slugs (optional) An array of specified product slugs.
250     * @return array Jetpack products on the site and their availability.
251     */
252    public static function get_products( $product_slugs = array() ) {
253        $all_classes = self::get_products_classes();
254        $products    = array();
255        // If an array of $product_slugs are passed, return only the products specified in $product_slugs array.
256        if ( $product_slugs ) {
257            foreach ( $product_slugs as $product_slug ) {
258                if ( isset( $all_classes[ $product_slug ] ) ) {
259                    $class                     = $all_classes[ $product_slug ];
260                    $products[ $product_slug ] = $class::get_info();
261                }
262            }
263
264            return $products;
265        }
266        // Otherwise return All products.
267        foreach ( $all_classes as $slug => $class ) {
268            $products[ $slug ] = $class::get_info();
269        }
270
271        return $products;
272    }
273
274    /**
275     * Get products data related to the wpcom api
276     *
277     * @param array $product_slugs - (optional) An array of specified product slugs.
278     * @return array
279     */
280    public static function get_products_api_data( $product_slugs = array() ) {
281        $all_classes = self::get_products_classes();
282        $products    = array();
283        // If an array of $product_slugs are passed, return only the products specified in $product_slugs array.
284        if ( $product_slugs ) {
285            foreach ( $product_slugs as $product_slug ) {
286                if ( isset( $all_classes[ $product_slug ] ) ) {
287                    $class                     = $all_classes[ $product_slug ];
288                    $products[ $product_slug ] = $class::get_wpcom_info();
289                }
290            }
291
292            return $products;
293        }
294        // Otherwise return All products.
295        foreach ( $all_classes as $slug => $class ) {
296            $products[ $slug ] = $class::get_wpcom_info();
297        }
298
299        return $products;
300    }
301
302    /**
303     * Get a list of products sorted by whether or not the user owns them
304     * An owned product is defined as a product that is any of the following
305     * - Active
306     * - Has historically been active
307     * - The user has a plan that includes the product
308     * - The user has the standalone plugin for the product installed
309     *
310     * @param string $type The type of ownership to return ('owned' or 'unowned').
311     *
312     * @return array
313     */
314    public static function get_products_by_ownership( $type ) {
315        $owned_active_products   = array();
316        $owned_warning_products  = array();
317        $owned_inactive_products = array();
318        $unowned_products        = array();
319
320        foreach ( self::get_products_classes() as $class ) {
321            $product_slug = $class::$slug;
322            $status       = $class::get_status();
323
324            if ( $class::is_owned() ) {
325                // This sorts the the products in the order of active -> warning -> inactive.
326                // This enables the frontend to display them in that order.
327                // This is not needed for unowned products as those will always have a status of 'inactive'.
328                if ( in_array( $status, self::$active_module_statuses, true ) ) {
329                    array_push( $owned_active_products, $product_slug );
330                } elseif ( in_array( $status, self::$warning_module_statuses, true ) ) {
331                    array_push( $owned_warning_products, $product_slug );
332                } else {
333                    array_push( $owned_inactive_products, $product_slug );
334                }
335                continue;
336            }
337
338            array_push( $unowned_products, $product_slug );
339        }
340
341        $data = array(
342            'owned'   => array_values(
343                array_unique(
344                    array_merge(
345                        $owned_active_products,
346                        $owned_warning_products,
347                        $owned_inactive_products
348                    )
349                )
350            ),
351            'unowned' => array_values(
352                array_unique( $unowned_products )
353            ),
354        );
355
356        return $data[ $type ];
357    }
358
359    /**
360     * Get all plugin filenames associated with the products.
361     *
362     * @return array
363     */
364    public static function get_all_plugin_filenames() {
365        $filenames = array();
366        foreach ( self::get_products_classes() as $class ) {
367            if ( ! isset( $class::$plugin_filename ) ) {
368                continue;
369            }
370
371            if ( is_array( $class::$plugin_filename ) ) {
372                $filenames = array_merge( $filenames, $class::$plugin_filename );
373            } else {
374                $filenames[] = $class::$plugin_filename;
375            }
376        }
377        return $filenames;
378    }
379
380    /**
381     * Get one product data by its slug
382     *
383     * @param string $product_slug The product slug.
384     *
385     * @return ?array
386     */
387    public static function get_product( $product_slug ) {
388        $classes = self::get_products_classes();
389        if ( isset( $classes[ $product_slug ] ) ) {
390            return $classes[ $product_slug ]::get_info();
391        }
392    }
393
394    /**
395     * Get one product Class name
396     *
397     * @param string $product_slug The product slug.
398     *
399     * @return ?string
400     */
401    public static function get_product_class( $product_slug ) {
402        $classes = self::get_products_classes();
403        if ( isset( $classes[ $product_slug ] ) ) {
404            return $classes[ $product_slug ];
405        }
406    }
407
408    /**
409     * Return product slugs list.
410     *
411     * @return array Product slugs array.
412     */
413    public static function get_products_slugs() {
414        return array_keys( self::get_products_classes() );
415    }
416
417    /**
418     * Gets the json schema for the product data
419     *
420     * @return array
421     */
422    public static function get_product_data_schema() {
423        return array(
424            'title'      => 'The requested product data',
425            'type'       => 'object',
426            'properties' => array(
427                'product'     => array(
428                    'description'       => __( 'Product slug', 'jetpack-my-jetpack' ),
429                    'type'              => 'string',
430                    'enum'              => __CLASS__ . '::get_product_slugs',
431                    'required'          => false,
432                    'validate_callback' => __CLASS__ . '::check_product_argument',
433                ),
434                'action'      => array(
435                    'description'       => __( 'Production action to execute', 'jetpack-my-jetpack' ),
436                    'type'              => 'string',
437                    'enum'              => array( 'activate', 'deactivate' ),
438                    'required'          => false,
439                    'validate_callback' => __CLASS__ . '::check_product_argument',
440                ),
441                'slug'        => array(
442                    'title' => 'The product slug',
443                    'type'  => 'string',
444                ),
445                'name'        => array(
446                    'title' => 'The product name',
447                    'type'  => 'string',
448                ),
449                'description' => array(
450                    'title' => 'The product description',
451                    'type'  => 'string',
452                ),
453                'status'      => array(
454                    'title' => 'The product status',
455                    'type'  => 'string',
456                    'enum'  => self::$all_statuses,
457                ),
458                'class'       => array(
459                    'title' => 'The product class handler',
460                    'type'  => 'string',
461                ),
462            ),
463        );
464    }
465
466    /**
467     * Extend actions links for plugins
468     * tied to the Products.
469     */
470    public static function extend_plugins_action_links() {
471        $products = array(
472            'backup',
473            'boost',
474            'crm',
475            'videopress',
476            'social',
477            'protect',
478            'crm',
479            'search',
480            'jetpack-ai',
481        );
482
483        // Add plugin action links for the core Jetpack plugin.
484        Product::extend_core_plugin_action_links();
485
486        // Add plugin action links to standalone products.
487        foreach ( $products as $product ) {
488            $class_name = self::get_product_class( $product );
489            $class_name::extend_plugin_action_links();
490        }
491    }
492
493    /**
494     * Get interstitials state for the products
495     *
496     * @return array A key-value array of product slugs and their interstitial states. True means the interstitial was seen by the user for that product.
497     */
498    public static function get_interstitials_state() {
499        return get_option( self::INTERSTITIALS_OPTION_NAME, array() );
500    }
501
502    /**
503     * Update interstitials state for the products
504     *
505     * @param array $new_state A key-value array of product slugs and their interstitial states.
506     *
507     * @return bool True if the option was updated successfully, false otherwise.
508     */
509    public static function update_interstitials_state( $new_state ) {
510
511        // Merge the existing interstitials state with the new state.
512        $interstitials_state = array_merge( self::get_interstitials_state(), $new_state );
513
514        return update_option( self::INTERSTITIALS_OPTION_NAME, $interstitials_state );
515    }
516}