Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
43.56% covered (danger)
43.56%
44 / 101
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Current_Plan
43.56% covered (danger)
43.56%
44 / 101
0.00% covered (danger)
0.00%
0 / 9
426.34
0.00% covered (danger)
0.00%
0 / 1
 update_from_sites_response
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
11.14
 store_data_in_option
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 refresh_from_wpcom
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 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
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
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     * The name of the option that will store the site's plan.
34     *
35     * @var string
36     */
37    const PLAN_OPTION = 'jetpack_active_plan';
38
39    /**
40     * The name of the option that will store the site's products.
41     *
42     * @var string
43     */
44    const SITE_PRODUCTS_OPTION = 'jetpack_site_products';
45
46    const PLAN_DATA = array(
47        'free'     => array(
48            'plans'    => array(
49                'jetpack_free',
50            ),
51            'supports' => array(
52                'advanced-seo',
53                'opentable',
54                'calendly',
55                'send-a-message',
56                'sharing-block',
57                'whatsapp-button',
58                'social-previews',
59                'videopress',
60                'videopress/video',
61                'v6-video-frame-poster',
62
63                'core/video',
64                'core/cover',
65                'core/audio',
66                'multistep-form',
67                'form-webhooks',
68            ),
69        ),
70        'personal' => array(
71            'plans'    => array(
72                'jetpack_personal',
73                'jetpack_personal_monthly',
74                'personal-bundle',
75                'personal-bundle-monthly',
76                'personal-bundle-2y',
77                'personal-bundle-3y',
78                'starter-plan',
79                'wp_bundle_choose_low_yearly',
80                'wp_bundle_choose_mid_yearly',
81                'wp_bundle_choose_high_yearly',
82            ),
83            'supports' => array(
84                'akismet',
85                'payments',
86                'videopress',
87            ),
88        ),
89        'premium'  => array(
90            'plans'    => array(
91                'jetpack_premium',
92                'jetpack_premium_monthly',
93                'value_bundle',
94                'value_bundle-monthly',
95                'value_bundle-2y',
96                'value_bundle-3y',
97                'jetpack_creator_yearly',
98                'jetpack_creator_bi_yearly',
99                'jetpack_creator_monthly',
100            ),
101            'supports' => array(
102                'simple-payments',
103                'vaultpress',
104                'videopress',
105                'republicize',
106            ),
107        ),
108        'security' => array(
109            'plans'    => array(
110                'jetpack_security_daily',
111                'jetpack_security_daily_monthly',
112                'jetpack_security_realtime',
113                'jetpack_security_realtime_monthly',
114                'jetpack_security_t1_yearly',
115                'jetpack_security_t1_monthly',
116                'jetpack_security_t2_yearly',
117                'jetpack_security_t2_monthly',
118            ),
119            'supports' => array(),
120        ),
121        'business' => array(
122            'plans'    => array(
123                'jetpack_business',
124                'jetpack_business_monthly',
125                'business-bundle',
126                'business-bundle-monthly',
127                'business-bundle-2y',
128                'business-bundle-3y',
129                'ecommerce-bundle',
130                'ecommerce-bundle-monthly',
131                'ecommerce-bundle-2y',
132                'ecommerce-bundle-3y',
133                'pro-plan',
134                'wp_bundle_migration_trial_monthly',
135                'wp_bundle_hosting_trial_monthly',
136                'ecommerce-trial-bundle-monthly',
137                'wooexpress-small-bundle-yearly',
138                'wooexpress-small-bundle-monthly',
139                'wooexpress-medium-bundle-yearly',
140                'wooexpress-medium-bundle-monthly',
141                'wp_com_hundred_year_bundle_centennially',
142            ),
143            'supports' => array(
144                'ai-seo-enhancer',
145            ),
146        ),
147
148        'complete' => array(
149            'plans'    => array(
150                'jetpack_complete',
151                'jetpack_complete_monthly',
152                'vip',
153            ),
154            'supports' => array(
155                'field-file', // Forms
156                'social-image-generator',
157            ),
158        ),
159    );
160
161    /**
162     * Given a response to the `/sites/%d` endpoint, will parse the response and attempt to set the
163     * site's plan and products from the response.
164     *
165     * @param array $response The response from `/sites/%d`.
166     * @return bool Was the plan successfully updated?
167     */
168    public static function update_from_sites_response( $response ) {
169        // Bail if there was an error or malformed response.
170        if ( is_wp_error( $response ) || ! is_array( $response ) || ! isset( $response['body'] ) ) {
171            return false;
172        }
173
174        $body = wp_remote_retrieve_body( $response );
175        if ( is_wp_error( $body ) ) {
176            return false;
177        }
178
179        // Decode the results.
180        $results = json_decode( $body, true );
181
182        if ( ! is_array( $results ) ) {
183            return false;
184        }
185
186        if ( isset( $results['products'] ) ) {
187            // Store the site's products in an option and return true if updated.
188            self::store_data_in_option( self::SITE_PRODUCTS_OPTION, $results['products'] );
189        }
190
191        if ( ! isset( $results['plan'] ) ) {
192            return false;
193        }
194
195        $current_plan = get_option( self::PLAN_OPTION, array() );
196
197        if ( ! empty( $current_plan ) && $current_plan === $results['plan'] ) {
198            // Bail if the plans array hasn't changed.
199            return false;
200        }
201
202        // Store the new plan in an option and return true if updated.
203        $result = self::store_data_in_option( self::PLAN_OPTION, $results['plan'] );
204
205        if ( $result ) {
206            // Reset the cache since we've just updated the plan.
207            self::$active_plan_cache = null;
208        }
209
210        return $result;
211    }
212
213    /**
214     * Store data in an option.
215     *
216     * @param string $option The name of the option that will store the data.
217     * @param array  $data Data to be store in an option.
218     * @return bool Were the subscriptions successfully updated?
219     */
220    private static function store_data_in_option( $option, $data ) {
221        $result = update_option( $option, $data, true );
222
223        // If something goes wrong with the update, so delete the current option and then update it.
224        if ( ! $result ) {
225            delete_option( $option );
226            $result = update_option( $option, $data, true );
227        }
228
229        return $result;
230    }
231
232    /**
233     * Make an API call to WordPress.com for plan status
234     *
235     * @uses Jetpack_Options::get_option()
236     * @uses Client::wpcom_json_api_request_as_blog()
237     * @uses update_option()
238     *
239     * @access public
240     * @static
241     *
242     * @return bool True if plan is updated, false if no update
243     */
244    public static function refresh_from_wpcom() {
245        $site_id = Manager::get_site_id();
246        if ( is_wp_error( $site_id ) ) {
247            return false;
248        }
249
250        // Make the API request.
251
252        $response = Client::wpcom_json_api_request_as_blog(
253            sprintf( '/sites/%d?force=wpcom', $site_id ),
254            '1.1'
255        );
256
257        return self::update_from_sites_response( $response );
258    }
259
260    /**
261     * Get the plan that this Jetpack site is currently using.
262     *
263     * @uses get_option()
264     *
265     * @access public
266     * @static
267     *
268     * @return array Active Jetpack plan details
269     */
270    public static function get() {
271        // this can be expensive to compute so we cache for the duration of a request.
272        if ( is_array( self::$active_plan_cache ) && ! empty( self::$active_plan_cache ) ) {
273            return self::$active_plan_cache;
274        }
275
276        $plan = get_option( self::PLAN_OPTION, array() );
277
278        // Set the default options.
279        $plan = wp_parse_args(
280            $plan,
281            array(
282                'product_slug' => 'jetpack_free',
283                'class'        => 'free',
284                'features'     => array(
285                    'active' => array(),
286                ),
287            )
288        );
289
290        list( $plan['class'], $supports ) = self::get_class_and_features( $plan['product_slug'] );
291
292        $modules = new Modules();
293        foreach ( $modules->get_available() as $module_slug ) {
294            $module = $modules->get( $module_slug );
295            if ( ! isset( $module ) || ! is_array( $module ) ) {
296                continue;
297            }
298            if ( in_array( 'free', $module['plan_classes'], true ) || in_array( $plan['class'], $module['plan_classes'], true ) ) {
299                $supports[] = $module_slug;
300            }
301        }
302
303        $plan['supports'] = $supports;
304
305        self::$active_plan_cache = $plan;
306
307        return $plan;
308    }
309
310    /**
311     * Get the site's products.
312     *
313     * @uses get_option()
314     *
315     * @access public
316     * @static
317     *
318     * @return array Active Jetpack products
319     */
320    public static function get_products() {
321        return get_option( self::SITE_PRODUCTS_OPTION, array() );
322    }
323
324    /**
325     * Get the class of plan and a list of features it supports
326     *
327     * @param string $plan_slug The plan that we're interested in.
328     * @return array Two item array, the plan class and the an array of features.
329     */
330    private static function get_class_and_features( $plan_slug ) {
331        $features = array();
332        foreach ( self::PLAN_DATA as $class => $details ) {
333            $features = array_merge( $features, $details['supports'] );
334            if ( in_array( $plan_slug, $details['plans'], true ) ) {
335                return array( $class, $features );
336            }
337        }
338        return array( 'free', self::PLAN_DATA['free']['supports'] );
339    }
340
341    /**
342     * Gets the minimum plan slug that supports the given feature
343     *
344     * @param string $feature The name of the feature.
345     * @return string|bool The slug for the minimum plan that supports.
346     *  the feature or false if not found
347     */
348    public static function get_minimum_plan_for_feature( $feature ) {
349        foreach ( self::PLAN_DATA as $details ) {
350            if ( in_array( $feature, $details['supports'], true ) ) {
351                return $details['plans'][0];
352            }
353        }
354        return false;
355    }
356
357    /**
358     * Determine whether the active plan supports a particular feature
359     *
360     * @uses self::get()
361     *
362     * @access public
363     * @static
364     *
365     * @param string $feature The module or feature to check.
366     * @param bool   $refresh_from_wpcom Refresh the local plan cache from wpcom.
367     *
368     * @return bool True if plan supports feature, false if not
369     */
370    public static function supports( $feature, $refresh_from_wpcom = false ) {
371        if ( $refresh_from_wpcom ) {
372            self::refresh_from_wpcom();
373        }
374
375        // Hijack the feature eligibility check on WordPress.com sites since they are gated differently.
376        $should_wpcom_gate_feature = (
377            function_exists( 'wpcom_site_has_feature' ) &&
378            function_exists( 'wpcom_feature_exists' ) &&
379            wpcom_feature_exists( $feature )
380        );
381        if ( $should_wpcom_gate_feature ) {
382            return wpcom_site_has_feature( $feature );
383        }
384
385        // Search product bypasses plan feature check.
386        if ( 'search' === $feature && (bool) get_option( 'has_jetpack_search_product' ) ) {
387            return true;
388        }
389
390        // As of Q3 2021 - a videopress free tier is available to all plans.
391        if ( 'videopress' === $feature ) {
392            return true;
393        }
394
395        // As of 05 2023 - all plans support Earn features (minus 'simple-payments').
396        if ( in_array( $feature, array( 'donations', 'recurring-payments', 'premium-content/container' ), true ) ) {
397            return true;
398        }
399
400        $plan = self::get();
401
402        if (
403            in_array( $feature, $plan['supports'], true )
404            || in_array( $feature, $plan['features']['active'], true )
405        ) {
406            return true;
407        }
408
409        return false;
410    }
411
412    /**
413     * Retrieve site-specific features for Simple sites.
414     *
415     * See Jetpack_Gutenberg::get_site_specific_features()
416     *
417     * @return array
418     */
419    public static function get_simple_site_specific_features() {
420        $is_simple_site = defined( 'IS_WPCOM' ) && constant( 'IS_WPCOM' );
421
422        if ( ! $is_simple_site ) {
423            return array(
424                'active'    => array(),
425                'available' => array(),
426            );
427        }
428
429        $current_blog_id = get_current_blog_id();
430
431        // Return the cached value if it exists.
432        if ( isset( self::$simple_site_specific_features[ $current_blog_id ] ) ) {
433            return self::$simple_site_specific_features[ $current_blog_id ];
434        }
435
436        if ( ! class_exists( '\Store_Product_List' ) ) {
437            require WP_CONTENT_DIR . '/admin-plugins/wpcom-billing/store-product-list.php';
438        }
439
440        $simple_site_specific_features = \Store_Product_List::get_site_specific_features_data( $current_blog_id );
441
442        self::$simple_site_specific_features[ $current_blog_id ] = $simple_site_specific_features;
443
444        return $simple_site_specific_features;
445    }
446}