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
205.59
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
10.90
 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        'pro'      => array(
167            'plans'    => array( 'jetpack_pro_yearly', 'jetpack_pro_bi_yearly', 'jetpack_pro_monthly' ),
168            'supports' => array(),
169        ),
170    );
171
172    /**
173     * Given a response to the `/sites/%d` endpoint, will parse the response and attempt to set the
174     * site's plan and products from the response.
175     *
176     * @param array $response The response from `/sites/%d`.
177     * @return bool Was the plan successfully updated?
178     */
179    public static function update_from_sites_response( $response ) {
180        // Bail if there was an error or malformed response.
181        if ( is_wp_error( $response ) || ! is_array( $response ) || ! isset( $response['body'] ) ) {
182            return false;
183        }
184
185        $body = wp_remote_retrieve_body( $response );
186        if ( is_wp_error( $body ) ) {
187            return false;
188        }
189
190        return self::update_from_site_record( json_decode( $body, true ) );
191    }
192
193    /**
194     * Given a decoded `/sites/%d` record, attempt to set the site's plan and products from it.
195     *
196     * @since 0.12.0
197     *
198     * @param array $record The decoded site record from the WordPress.com `/sites/%d` endpoint.
199     * @return bool Was the plan successfully updated?
200     */
201    public static function update_from_site_record( $record ) {
202        if ( ! is_array( $record ) ) {
203            return false;
204        }
205
206        if ( isset( $record['products'] ) ) {
207            // Store the site's products in an option and return true if updated.
208            self::store_data_in_option( self::SITE_PRODUCTS_OPTION, $record['products'] );
209        }
210
211        if ( ! isset( $record['plan'] ) ) {
212            return false;
213        }
214
215        $current_plan = get_option( self::PLAN_OPTION, array() );
216
217        if ( ! empty( $current_plan ) && $current_plan === $record['plan'] ) {
218            // Bail if the plans array hasn't changed.
219            return false;
220        }
221
222        // Store the new plan in an option and return true if updated.
223        $result = self::store_data_in_option( self::PLAN_OPTION, $record['plan'] );
224
225        if ( $result ) {
226            // Reset the cache since we've just updated the plan.
227            self::$active_plan_cache = null;
228        }
229
230        return $result;
231    }
232
233    /**
234     * Store data in an option.
235     *
236     * @param string $option The name of the option that will store the data.
237     * @param array  $data Data to be store in an option.
238     * @return bool Were the subscriptions successfully updated?
239     */
240    private static function store_data_in_option( $option, $data ) {
241        $result = update_option( $option, $data, true );
242
243        if ( $result ) {
244            return true;
245        }
246
247        // update_option() also reports false when the stored value already matches, which is not a
248        // failure. Both options are autoloaded, so reading it as one rewrites them on every
249        // unchanged fetch and drops the alloptions cache with it.
250        if ( get_option( $option ) === $data ) {
251            return true;
252        }
253
254        // If the update genuinely failed, delete the option and write it again.
255        delete_option( $option );
256
257        return update_option( $option, $data, true );
258    }
259
260    /**
261     * Make an API call to WordPress.com for plan status
262     *
263     * @uses Jetpack_Options::get_option()
264     * @uses Client::wpcom_json_api_request_as_blog()
265     * @uses update_option()
266     *
267     * @access public
268     * @static
269     *
270     * @since 0.14.0 Accepts request arguments.
271     *
272     * @param array $args Request arguments, as accepted by `Client::wpcom_json_api_request_as_blog()`.
273     *                    A caller refreshing in front of a page render can cap `timeout` here; the
274     *                    `http_request_timeout` filter cannot, because the client always sends one.
275     * @return bool True if plan is updated, false if no update
276     */
277    public static function refresh_from_wpcom( $args = array() ) {
278        // Also registered as an action callback, and `do_action()` hands those an empty string.
279        if ( ! is_array( $args ) ) {
280            $args = array();
281        }
282
283        $site_id = Manager::get_site_id();
284        if ( is_wp_error( $site_id ) ) {
285            return false;
286        }
287
288        // Make the API request.
289
290        $response = Client::wpcom_json_api_request_as_blog(
291            sprintf( '/sites/%d?force=wpcom', $site_id ),
292            '1.1',
293            $args
294        );
295
296        $updated = self::update_from_sites_response( $response );
297
298        // The shared site record cache can still hold a record older than this response, and a
299        // cached read stores the plan again. Dropping it keeps that older record from reverting
300        // what this fetch just stored.
301        if ( ! is_wp_error( $response ) ) {
302            Manager::delete_cached_site_data();
303        }
304
305        return $updated;
306    }
307
308    /**
309     * Get the plan that this Jetpack site is currently using.
310     *
311     * @uses get_option()
312     *
313     * @access public
314     * @static
315     *
316     * @return array Active Jetpack plan details
317     */
318    public static function get() {
319        // this can be expensive to compute so we cache for the duration of a request.
320        if ( is_array( self::$active_plan_cache ) && ! empty( self::$active_plan_cache ) ) {
321            return self::$active_plan_cache;
322        }
323
324        $plan = get_option( self::PLAN_OPTION, array() );
325
326        // Set the default options.
327        $plan = wp_parse_args(
328            $plan,
329            array(
330                'product_slug' => 'jetpack_free',
331                'class'        => 'free',
332                'features'     => array(
333                    'active' => array(),
334                ),
335            )
336        );
337
338        list( $plan['class'], $supports ) = self::get_class_and_features( $plan['product_slug'] );
339
340        $modules = new Modules();
341        foreach ( $modules->get_available() as $module_slug ) {
342            $module = $modules->get( $module_slug );
343            if ( ! isset( $module ) || ! is_array( $module ) ) {
344                continue;
345            }
346            if ( in_array( 'free', $module['plan_classes'], true ) || in_array( $plan['class'], $module['plan_classes'], true ) || ( 'pro' === $plan['class'] && in_array( 'complete', $module['plan_classes'], true ) ) ) {
347                $supports[] = $module_slug;
348            }
349        }
350
351        $plan['supports'] = $supports;
352
353        self::$active_plan_cache = $plan;
354
355        return $plan;
356    }
357
358    /**
359     * Get the site's products.
360     *
361     * @uses get_option()
362     *
363     * @access public
364     * @static
365     *
366     * @return array Active Jetpack products
367     */
368    public static function get_products() {
369        return get_option( self::SITE_PRODUCTS_OPTION, array() );
370    }
371
372    /**
373     * Get the class of plan and a list of features it supports
374     *
375     * @param string $plan_slug The plan that we're interested in.
376     * @return array Two item array, the plan class and the an array of features.
377     */
378    private static function get_class_and_features( $plan_slug ) {
379        $features = array();
380        foreach ( self::PLAN_DATA as $class => $details ) {
381            $features = array_merge( $features, $details['supports'] );
382            if ( in_array( $plan_slug, $details['plans'], true ) ) {
383                return array( $class, $features );
384            }
385        }
386        return array( 'free', self::PLAN_DATA['free']['supports'] );
387    }
388
389    /**
390     * Gets the minimum plan slug that supports the given feature
391     *
392     * @param string $feature The name of the feature.
393     * @return string|bool The slug for the minimum plan that supports.
394     *  the feature or false if not found
395     */
396    public static function get_minimum_plan_for_feature( $feature ) {
397        foreach ( self::PLAN_DATA as $details ) {
398            if ( in_array( $feature, $details['supports'], true ) ) {
399                return $details['plans'][0];
400            }
401        }
402        return false;
403    }
404
405    /**
406     * Determine whether the active plan supports a particular feature
407     *
408     * @uses self::get()
409     *
410     * @access public
411     * @static
412     *
413     * @param string $feature The module or feature to check.
414     * @param bool   $refresh_from_wpcom Refresh the local plan cache from wpcom.
415     *
416     * @return bool True if plan supports feature, false if not
417     */
418    public static function supports( $feature, $refresh_from_wpcom = false ) {
419        if ( $refresh_from_wpcom ) {
420            self::refresh_from_wpcom();
421        }
422
423        // Hijack the feature eligibility check on WordPress.com sites since they are gated differently.
424        $should_wpcom_gate_feature = (
425            function_exists( 'wpcom_site_has_feature' ) &&
426            function_exists( 'wpcom_feature_exists' ) &&
427            wpcom_feature_exists( $feature )
428        );
429        if ( $should_wpcom_gate_feature ) {
430            return wpcom_site_has_feature( $feature );
431        }
432
433        // Search product bypasses plan feature check.
434        if ( 'search' === $feature && (bool) get_option( 'has_jetpack_search_product' ) ) {
435            return true;
436        }
437
438        // As of Q3 2021 - a videopress free tier is available to all plans.
439        if ( 'videopress' === $feature ) {
440            return true;
441        }
442
443        // As of 05 2023 - all plans support Earn features (minus 'simple-payments').
444        if ( in_array( $feature, array( 'donations', 'recurring-payments', 'premium-content/container' ), true ) ) {
445            return true;
446        }
447
448        $plan = self::get();
449
450        if (
451            in_array( $feature, $plan['supports'], true )
452            || in_array( $feature, $plan['features']['active'], true )
453        ) {
454            return true;
455        }
456
457        return false;
458    }
459
460    /**
461     * Retrieve site-specific features for Simple sites.
462     *
463     * See Jetpack_Gutenberg::get_site_specific_features()
464     *
465     * @param bool $include_available Whether to include upgradeable features, which requires a billing catalog lookup.
466     * @return array
467     */
468    public static function get_simple_site_specific_features( $include_available = true ) {
469        $is_simple_site = Constants::is_true( 'IS_WPCOM' );
470
471        if ( ! $is_simple_site ) {
472            return array(
473                'active'    => array(),
474                'available' => array(),
475            );
476        }
477
478        $current_blog_id = get_current_blog_id();
479        $cache_key       = $include_available ? 'full' : 'active_only';
480
481        // Return the cached value if it exists. A full result answers an active-only
482        // request too, so that one is reused rather than recomputed.
483        if ( isset( self::$simple_site_specific_features[ $current_blog_id ][ $cache_key ] ) ) {
484            return self::$simple_site_specific_features[ $current_blog_id ][ $cache_key ];
485        }
486        if ( ! $include_available && isset( self::$simple_site_specific_features[ $current_blog_id ]['full'] ) ) {
487            return self::$simple_site_specific_features[ $current_blog_id ]['full'];
488        }
489
490        if ( ! class_exists( '\Store_Product_List' ) ) {
491            require WP_CONTENT_DIR . '/admin-plugins/wpcom-billing/store-product-list.php';
492        }
493
494        $simple_site_specific_features = \Store_Product_List::get_site_specific_features_data( $current_blog_id, $include_available );
495
496        self::$simple_site_specific_features[ $current_blog_id ][ $cache_key ] = $simple_site_specific_features;
497
498        return $simple_site_specific_features;
499    }
500
501    /**
502     * Retrieve site-specific features from the WordPress.com registry the site itself carries.
503     *
504     * Simple sites read the store; Atomic sites read the purchases wpcomsh keeps in sync. Both
505     * answer as of this request, where `self::PLAN_OPTION` is only as current as its last fetch.
506     *
507     * @since 0.14.0
508     *
509     * @return array|null Active and available features, or null where the site carries no registry.
510     */
511    public static function get_wpcom_site_specific_features() {
512        if ( defined( 'IS_WPCOM' ) && constant( 'IS_WPCOM' ) ) {
513            return self::get_simple_site_specific_features();
514        }
515
516        if (
517            ! Constants::is_true( 'IS_ATOMIC' )
518            || ! class_exists( '\WPCOM_Features' )
519            || ! function_exists( 'wpcom_get_site_purchases' )
520        ) {
521            return null;
522        }
523
524        if ( null !== self::$atomic_site_specific_features ) {
525            return self::$atomic_site_specific_features;
526        }
527
528        // No blog ID anywhere below. wpcomsh resolves it from `jetpack_options`, where WordPress
529        // reports 1, and throws when the two disagree.
530        $purchases = \wpcom_get_site_purchases();
531
532        /*
533         * A site whose Atomic persistent data has not synced reads exactly like one that bought
534         * nothing, and answering would gate a paid site. Only WordPress.com can tell the two
535         * apart, so report no answer and leave the caller its own fallback.
536         */
537        if ( ! $purchases ) {
538            return null;
539        }
540
541        $active = array();
542
543        foreach ( \WPCOM_Features::get_feature_slugs() as $feature ) {
544            if ( \WPCOM_Features::has_feature( $feature, $purchases, 'wpcom' ) ) {
545                $active[] = $feature;
546            }
547        }
548
549        // `available` names the plans that would grant each feature the site lacks, which only the
550        // WordPress.com product catalogue can answer. Callers here read `active`.
551        self::$atomic_site_specific_features = array(
552            'active'    => $active,
553            'available' => array(),
554        );
555
556        return self::$atomic_site_specific_features;
557    }
558}