Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.42% covered (warning)
67.42%
89 / 132
9.09% covered (danger)
9.09%
1 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Current_Plan
67.42% covered (warning)
67.42%
89 / 132
9.09% covered (danger)
9.09%
1 / 11
194.88
0.00% covered (danger)
0.00%
0 / 1
 update_from_sites_response
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 update_from_site_record
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 store_data_in_option
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 refresh_from_wpcom
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 get
79.17% covered (warning)
79.17%
19 / 24
0.00% covered (danger)
0.00%
0 / 1
8.58
 get_products
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_class_and_features
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 get_minimum_plan_for_feature
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 supports
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
132
 get_simple_site_specific_features
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
9.15
 get_wpcom_site_specific_features
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
10.10
1<?php
2/**
3 * Handles fetching of the site's plan and products from WordPress.com and caching values locally.
4 *
5 * @package automattic/jetpack-plans
6 */
7
8namespace Automattic\Jetpack;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager;
12
13/**
14 * Provides methods methods for fetching the site's plan and products from WordPress.com.
15 */
16class Current_Plan {
17    /**
18     * A cache variable to hold the active plan for the current request.
19     *
20     * @var array
21     */
22    private static $active_plan_cache;
23
24    /**
25     * Simple Site-specific features available.
26     * Their calculation can be expensive and slow, so we're caching it for the request.
27     *
28     * @var array Site-specific features
29     */
30    private static $simple_site_specific_features = array();
31
32    /**
33     * Atomic site-specific features available, cached for the request alongside the Simple ones.
34     *
35     * Not keyed on a blog ID: an Atomic site can only ever answer for itself.
36     *
37     * @var array|null Site-specific features.
38     */
39    private static $atomic_site_specific_features = null;
40
41    /**
42     * The name of the option that will store the site's plan.
43     *
44     * @var string
45     */
46    const PLAN_OPTION = 'jetpack_active_plan';
47
48    /**
49     * The name of the option that will store the site's products.
50     *
51     * @var string
52     */
53    const SITE_PRODUCTS_OPTION = 'jetpack_site_products';
54
55    const PLAN_DATA = array(
56        'free'     => array(
57            'plans'    => array(
58                'jetpack_free',
59            ),
60            'supports' => array(
61                'advanced-seo',
62                'opentable',
63                'calendly',
64                'send-a-message',
65                'sharing-block',
66                'whatsapp-button',
67                'social-previews',
68                'videopress',
69                'videopress/video',
70                'v6-video-frame-poster',
71
72                'core/video',
73                'core/cover',
74                'core/audio',
75                'multistep-form',
76                'form-webhooks',
77                'form-conditional-logic',
78            ),
79        ),
80        'personal' => array(
81            'plans'    => array(
82                'jetpack_personal',
83                'jetpack_personal_monthly',
84                'personal-bundle',
85                'personal-bundle-monthly',
86                'personal-bundle-2y',
87                'personal-bundle-3y',
88                'starter-plan',
89            ),
90            'supports' => array(
91                'akismet',
92                'payments',
93                'videopress',
94            ),
95        ),
96        'premium'  => array(
97            'plans'    => array(
98                'jetpack_premium',
99                'jetpack_premium_monthly',
100                'value_bundle',
101                'value_bundle-monthly',
102                'value_bundle-2y',
103                'value_bundle-3y',
104                'jetpack_creator_yearly',
105                'jetpack_creator_bi_yearly',
106                'jetpack_creator_monthly',
107            ),
108            'supports' => array(
109                'simple-payments',
110                'vaultpress',
111                'videopress',
112                'republicize',
113            ),
114        ),
115        'security' => array(
116            'plans'    => array(
117                'jetpack_security_daily',
118                'jetpack_security_daily_monthly',
119                'jetpack_security_realtime',
120                'jetpack_security_realtime_monthly',
121                'jetpack_security_t1_yearly',
122                'jetpack_security_t1_monthly',
123                'jetpack_security_t2_yearly',
124                'jetpack_security_t2_monthly',
125            ),
126            'supports' => array(),
127        ),
128        'business' => array(
129            'plans'    => array(
130                'jetpack_business',
131                'jetpack_business_monthly',
132                'business-bundle',
133                'business-bundle-monthly',
134                'business-bundle-2y',
135                'business-bundle-3y',
136                'ecommerce-bundle',
137                'ecommerce-bundle-monthly',
138                'ecommerce-bundle-2y',
139                'ecommerce-bundle-3y',
140                'pro-plan',
141                'wp_bundle_migration_trial_monthly',
142                'wp_bundle_hosting_trial_monthly',
143                'ecommerce-trial-bundle-monthly',
144                'wooexpress-small-bundle-yearly',
145                'wooexpress-small-bundle-monthly',
146                'wooexpress-medium-bundle-yearly',
147                'wooexpress-medium-bundle-monthly',
148                'wp_com_hundred_year_bundle_centennially',
149            ),
150            'supports' => array(
151                'ai-seo-enhancer',
152            ),
153        ),
154
155        'complete' => array(
156            'plans'    => array(
157                'jetpack_complete',
158                'jetpack_complete_monthly',
159                'vip',
160            ),
161            'supports' => array(
162                'field-file', // Forms
163                'social-image-generator',
164            ),
165        ),
166    );
167
168    /**
169     * Given a response to the `/sites/%d` endpoint, will parse the response and attempt to set the
170     * site's plan and products from the response.
171     *
172     * @param array $response The response from `/sites/%d`.
173     * @return bool Was the plan successfully updated?
174     */
175    public static function update_from_sites_response( $response ) {
176        // Bail if there was an error or malformed response.
177        if ( is_wp_error( $response ) || ! is_array( $response ) || ! isset( $response['body'] ) ) {
178            return false;
179        }
180
181        $body = wp_remote_retrieve_body( $response );
182        if ( is_wp_error( $body ) ) {
183            return false;
184        }
185
186        return self::update_from_site_record( json_decode( $body, true ) );
187    }
188
189    /**
190     * Given a decoded `/sites/%d` record, attempt to set the site's plan and products from it.
191     *
192     * @since 0.12.0
193     *
194     * @param array $record The decoded site record from the WordPress.com `/sites/%d` endpoint.
195     * @return bool Was the plan successfully updated?
196     */
197    public static function update_from_site_record( $record ) {
198        if ( ! is_array( $record ) ) {
199            return false;
200        }
201
202        if ( isset( $record['products'] ) ) {
203            // Store the site's products in an option and return true if updated.
204            self::store_data_in_option( self::SITE_PRODUCTS_OPTION, $record['products'] );
205        }
206
207        if ( ! isset( $record['plan'] ) ) {
208            return false;
209        }
210
211        $current_plan = get_option( self::PLAN_OPTION, array() );
212
213        if ( ! empty( $current_plan ) && $current_plan === $record['plan'] ) {
214            // Bail if the plans array hasn't changed.
215            return false;
216        }
217
218        // Store the new plan in an option and return true if updated.
219        $result = self::store_data_in_option( self::PLAN_OPTION, $record['plan'] );
220
221        if ( $result ) {
222            // Reset the cache since we've just updated the plan.
223            self::$active_plan_cache = null;
224        }
225
226        return $result;
227    }
228
229    /**
230     * Store data in an option.
231     *
232     * @param string $option The name of the option that will store the data.
233     * @param array  $data Data to be store in an option.
234     * @return bool Were the subscriptions successfully updated?
235     */
236    private static function store_data_in_option( $option, $data ) {
237        $result = update_option( $option, $data, true );
238
239        if ( $result ) {
240            return true;
241        }
242
243        // update_option() also reports false when the stored value already matches, which is not a
244        // failure. Both options are autoloaded, so reading it as one rewrites them on every
245        // unchanged fetch and drops the alloptions cache with it.
246        if ( get_option( $option ) === $data ) {
247            return true;
248        }
249
250        // If the update genuinely failed, delete the option and write it again.
251        delete_option( $option );
252
253        return update_option( $option, $data, true );
254    }
255
256    /**
257     * Make an API call to WordPress.com for plan status
258     *
259     * @uses Jetpack_Options::get_option()
260     * @uses Client::wpcom_json_api_request_as_blog()
261     * @uses update_option()
262     *
263     * @access public
264     * @static
265     *
266     * @since 0.14.0 Accepts request arguments.
267     *
268     * @param array $args Request arguments, as accepted by `Client::wpcom_json_api_request_as_blog()`.
269     *                    A caller refreshing in front of a page render can cap `timeout` here; the
270     *                    `http_request_timeout` filter cannot, because the client always sends one.
271     * @return bool True if plan is updated, false if no update
272     */
273    public static function refresh_from_wpcom( $args = array() ) {
274        // Also registered as an action callback, and `do_action()` hands those an empty string.
275        if ( ! is_array( $args ) ) {
276            $args = array();
277        }
278
279        $site_id = Manager::get_site_id();
280        if ( is_wp_error( $site_id ) ) {
281            return false;
282        }
283
284        // Make the API request.
285
286        $response = Client::wpcom_json_api_request_as_blog(
287            sprintf( '/sites/%d?force=wpcom', $site_id ),
288            '1.1',
289            $args
290        );
291
292        $updated = self::update_from_sites_response( $response );
293
294        // The shared site record cache can still hold a record older than this response, and a
295        // cached read stores the plan again. Dropping it keeps that older record from reverting
296        // what this fetch just stored.
297        if ( ! is_wp_error( $response ) ) {
298            Manager::delete_cached_site_data();
299        }
300
301        return $updated;
302    }
303
304    /**
305     * Get the plan that this Jetpack site is currently using.
306     *
307     * @uses get_option()
308     *
309     * @access public
310     * @static
311     *
312     * @return array Active Jetpack plan details
313     */
314    public static function get() {
315        // this can be expensive to compute so we cache for the duration of a request.
316        if ( is_array( self::$active_plan_cache ) && ! empty( self::$active_plan_cache ) ) {
317            return self::$active_plan_cache;
318        }
319
320        $plan = get_option( self::PLAN_OPTION, array() );
321
322        // Set the default options.
323        $plan = wp_parse_args(
324            $plan,
325            array(
326                'product_slug' => 'jetpack_free',
327                'class'        => 'free',
328                'features'     => array(
329                    'active' => array(),
330                ),
331            )
332        );
333
334        list( $plan['class'], $supports ) = self::get_class_and_features( $plan['product_slug'] );
335
336        $modules = new Modules();
337        foreach ( $modules->get_available() as $module_slug ) {
338            $module = $modules->get( $module_slug );
339            if ( ! isset( $module ) || ! is_array( $module ) ) {
340                continue;
341            }
342            if ( in_array( 'free', $module['plan_classes'], true ) || in_array( $plan['class'], $module['plan_classes'], true ) ) {
343                $supports[] = $module_slug;
344            }
345        }
346
347        $plan['supports'] = $supports;
348
349        self::$active_plan_cache = $plan;
350
351        return $plan;
352    }
353
354    /**
355     * Get the site's products.
356     *
357     * @uses get_option()
358     *
359     * @access public
360     * @static
361     *
362     * @return array Active Jetpack products
363     */
364    public static function get_products() {
365        return get_option( self::SITE_PRODUCTS_OPTION, array() );
366    }
367
368    /**
369     * Get the class of plan and a list of features it supports
370     *
371     * @param string $plan_slug The plan that we're interested in.
372     * @return array Two item array, the plan class and the an array of features.
373     */
374    private static function get_class_and_features( $plan_slug ) {
375        $features = array();
376        foreach ( self::PLAN_DATA as $class => $details ) {
377            $features = array_merge( $features, $details['supports'] );
378            if ( in_array( $plan_slug, $details['plans'], true ) ) {
379                return array( $class, $features );
380            }
381        }
382        return array( 'free', self::PLAN_DATA['free']['supports'] );
383    }
384
385    /**
386     * Gets the minimum plan slug that supports the given feature
387     *
388     * @param string $feature The name of the feature.
389     * @return string|bool The slug for the minimum plan that supports.
390     *  the feature or false if not found
391     */
392    public static function get_minimum_plan_for_feature( $feature ) {
393        foreach ( self::PLAN_DATA as $details ) {
394            if ( in_array( $feature, $details['supports'], true ) ) {
395                return $details['plans'][0];
396            }
397        }
398        return false;
399    }
400
401    /**
402     * Determine whether the active plan supports a particular feature
403     *
404     * @uses self::get()
405     *
406     * @access public
407     * @static
408     *
409     * @param string $feature The module or feature to check.
410     * @param bool   $refresh_from_wpcom Refresh the local plan cache from wpcom.
411     *
412     * @return bool True if plan supports feature, false if not
413     */
414    public static function supports( $feature, $refresh_from_wpcom = false ) {
415        if ( $refresh_from_wpcom ) {
416            self::refresh_from_wpcom();
417        }
418
419        // Hijack the feature eligibility check on WordPress.com sites since they are gated differently.
420        $should_wpcom_gate_feature = (
421            function_exists( 'wpcom_site_has_feature' ) &&
422            function_exists( 'wpcom_feature_exists' ) &&
423            wpcom_feature_exists( $feature )
424        );
425        if ( $should_wpcom_gate_feature ) {
426            return wpcom_site_has_feature( $feature );
427        }
428
429        // Search product bypasses plan feature check.
430        if ( 'search' === $feature && (bool) get_option( 'has_jetpack_search_product' ) ) {
431            return true;
432        }
433
434        // As of Q3 2021 - a videopress free tier is available to all plans.
435        if ( 'videopress' === $feature ) {
436            return true;
437        }
438
439        // As of 05 2023 - all plans support Earn features (minus 'simple-payments').
440        if ( in_array( $feature, array( 'donations', 'recurring-payments', 'premium-content/container' ), true ) ) {
441            return true;
442        }
443
444        $plan = self::get();
445
446        if (
447            in_array( $feature, $plan['supports'], true )
448            || in_array( $feature, $plan['features']['active'], true )
449        ) {
450            return true;
451        }
452
453        return false;
454    }
455
456    /**
457     * Retrieve site-specific features for Simple sites.
458     *
459     * See Jetpack_Gutenberg::get_site_specific_features()
460     *
461     * @param bool $include_available Whether to include upgradeable features, which requires a billing catalog lookup.
462     * @return array
463     */
464    public static function get_simple_site_specific_features( $include_available = true ) {
465        $is_simple_site = Constants::is_true( 'IS_WPCOM' );
466
467        if ( ! $is_simple_site ) {
468            return array(
469                'active'    => array(),
470                'available' => array(),
471            );
472        }
473
474        $current_blog_id = get_current_blog_id();
475        $cache_key       = $include_available ? 'full' : 'active_only';
476
477        // Return the cached value if it exists. A full result answers an active-only
478        // request too, so that one is reused rather than recomputed.
479        if ( isset( self::$simple_site_specific_features[ $current_blog_id ][ $cache_key ] ) ) {
480            return self::$simple_site_specific_features[ $current_blog_id ][ $cache_key ];
481        }
482        if ( ! $include_available && isset( self::$simple_site_specific_features[ $current_blog_id ]['full'] ) ) {
483            return self::$simple_site_specific_features[ $current_blog_id ]['full'];
484        }
485
486        if ( ! class_exists( '\Store_Product_List' ) ) {
487            require WP_CONTENT_DIR . '/admin-plugins/wpcom-billing/store-product-list.php';
488        }
489
490        $simple_site_specific_features = \Store_Product_List::get_site_specific_features_data( $current_blog_id, $include_available );
491
492        self::$simple_site_specific_features[ $current_blog_id ][ $cache_key ] = $simple_site_specific_features;
493
494        return $simple_site_specific_features;
495    }
496
497    /**
498     * Retrieve site-specific features from the WordPress.com registry the site itself carries.
499     *
500     * Simple sites read the store; Atomic sites read the purchases wpcomsh keeps in sync. Both
501     * answer as of this request, where `self::PLAN_OPTION` is only as current as its last fetch.
502     *
503     * @since 0.14.0
504     *
505     * @return array|null Active and available features, or null where the site carries no registry.
506     */
507    public static function get_wpcom_site_specific_features() {
508        if ( defined( 'IS_WPCOM' ) && constant( 'IS_WPCOM' ) ) {
509            return self::get_simple_site_specific_features();
510        }
511
512        if (
513            ! Constants::is_true( 'IS_ATOMIC' )
514            || ! class_exists( '\WPCOM_Features' )
515            || ! function_exists( 'wpcom_get_site_purchases' )
516        ) {
517            return null;
518        }
519
520        if ( null !== self::$atomic_site_specific_features ) {
521            return self::$atomic_site_specific_features;
522        }
523
524        // No blog ID anywhere below. wpcomsh resolves it from `jetpack_options`, where WordPress
525        // reports 1, and throws when the two disagree.
526        $purchases = \wpcom_get_site_purchases();
527
528        /*
529         * A site whose Atomic persistent data has not synced reads exactly like one that bought
530         * nothing, and answering would gate a paid site. Only WordPress.com can tell the two
531         * apart, so report no answer and leave the caller its own fallback.
532         */
533        if ( ! $purchases ) {
534            return null;
535        }
536
537        $active = array();
538
539        foreach ( \WPCOM_Features::get_feature_slugs() as $feature ) {
540            if ( \WPCOM_Features::has_feature( $feature, $purchases, 'wpcom' ) ) {
541                $active[] = $feature;
542            }
543        }
544
545        // `available` names the plans that would grant each feature the site lacks, which only the
546        // WordPress.com product catalogue can answer. Callers here read `active`.
547        self::$atomic_site_specific_features = array(
548            'active'    => $active,
549            'available' => array(),
550        );
551
552        return self::$atomic_site_specific_features;
553    }
554}