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