Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.77% covered (warning)
53.77%
57 / 106
10.00% covered (danger)
10.00%
1 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Current_Plan
53.77% covered (warning)
53.77%
57 / 106
10.00% covered (danger)
10.00%
1 / 10
286.17
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
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.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
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        return self::update_from_site_record( json_decode( $body, true ) );
177    }
178
179    /**
180     * Given a decoded `/sites/%d` record, attempt to set the site's plan and products from it.
181     *
182     * @since 0.12.0
183     *
184     * @param array $record The decoded site record from the WordPress.com `/sites/%d` endpoint.
185     * @return bool Was the plan successfully updated?
186     */
187    public static function update_from_site_record( $record ) {
188        if ( ! is_array( $record ) ) {
189            return false;
190        }
191
192        if ( isset( $record['products'] ) ) {
193            // Store the site's products in an option and return true if updated.
194            self::store_data_in_option( self::SITE_PRODUCTS_OPTION, $record['products'] );
195        }
196
197        if ( ! isset( $record['plan'] ) ) {
198            return false;
199        }
200
201        $current_plan = get_option( self::PLAN_OPTION, array() );
202
203        if ( ! empty( $current_plan ) && $current_plan === $record['plan'] ) {
204            // Bail if the plans array hasn't changed.
205            return false;
206        }
207
208        // Store the new plan in an option and return true if updated.
209        $result = self::store_data_in_option( self::PLAN_OPTION, $record['plan'] );
210
211        if ( $result ) {
212            // Reset the cache since we've just updated the plan.
213            self::$active_plan_cache = null;
214        }
215
216        return $result;
217    }
218
219    /**
220     * Store data in an option.
221     *
222     * @param string $option The name of the option that will store the data.
223     * @param array  $data Data to be store in an option.
224     * @return bool Were the subscriptions successfully updated?
225     */
226    private static function store_data_in_option( $option, $data ) {
227        $result = update_option( $option, $data, true );
228
229        if ( $result ) {
230            return true;
231        }
232
233        // update_option() also reports false when the stored value already matches, which is not a
234        // failure. Both options are autoloaded, so reading it as one rewrites them on every
235        // unchanged fetch and drops the alloptions cache with it.
236        if ( get_option( $option ) === $data ) {
237            return true;
238        }
239
240        // If the update genuinely failed, delete the option and write it again.
241        delete_option( $option );
242
243        return update_option( $option, $data, true );
244    }
245
246    /**
247     * Make an API call to WordPress.com for plan status
248     *
249     * @uses Jetpack_Options::get_option()
250     * @uses Client::wpcom_json_api_request_as_blog()
251     * @uses update_option()
252     *
253     * @access public
254     * @static
255     *
256     * @return bool True if plan is updated, false if no update
257     */
258    public static function refresh_from_wpcom() {
259        $site_id = Manager::get_site_id();
260        if ( is_wp_error( $site_id ) ) {
261            return false;
262        }
263
264        // Make the API request.
265
266        $response = Client::wpcom_json_api_request_as_blog(
267            sprintf( '/sites/%d?force=wpcom', $site_id ),
268            '1.1'
269        );
270
271        $updated = self::update_from_sites_response( $response );
272
273        // The shared site record cache can still hold a record older than this response, and a
274        // cached read stores the plan again. Dropping it keeps that older record from reverting
275        // what this fetch just stored.
276        if ( ! is_wp_error( $response ) ) {
277            Manager::delete_cached_site_data();
278        }
279
280        return $updated;
281    }
282
283    /**
284     * Get the plan that this Jetpack site is currently using.
285     *
286     * @uses get_option()
287     *
288     * @access public
289     * @static
290     *
291     * @return array Active Jetpack plan details
292     */
293    public static function get() {
294        // this can be expensive to compute so we cache for the duration of a request.
295        if ( is_array( self::$active_plan_cache ) && ! empty( self::$active_plan_cache ) ) {
296            return self::$active_plan_cache;
297        }
298
299        $plan = get_option( self::PLAN_OPTION, array() );
300
301        // Set the default options.
302        $plan = wp_parse_args(
303            $plan,
304            array(
305                'product_slug' => 'jetpack_free',
306                'class'        => 'free',
307                'features'     => array(
308                    'active' => array(),
309                ),
310            )
311        );
312
313        list( $plan['class'], $supports ) = self::get_class_and_features( $plan['product_slug'] );
314
315        $modules = new Modules();
316        foreach ( $modules->get_available() as $module_slug ) {
317            $module = $modules->get( $module_slug );
318            if ( ! isset( $module ) || ! is_array( $module ) ) {
319                continue;
320            }
321            if ( in_array( 'free', $module['plan_classes'], true ) || in_array( $plan['class'], $module['plan_classes'], true ) ) {
322                $supports[] = $module_slug;
323            }
324        }
325
326        $plan['supports'] = $supports;
327
328        self::$active_plan_cache = $plan;
329
330        return $plan;
331    }
332
333    /**
334     * Get the site's products.
335     *
336     * @uses get_option()
337     *
338     * @access public
339     * @static
340     *
341     * @return array Active Jetpack products
342     */
343    public static function get_products() {
344        return get_option( self::SITE_PRODUCTS_OPTION, array() );
345    }
346
347    /**
348     * Get the class of plan and a list of features it supports
349     *
350     * @param string $plan_slug The plan that we're interested in.
351     * @return array Two item array, the plan class and the an array of features.
352     */
353    private static function get_class_and_features( $plan_slug ) {
354        $features = array();
355        foreach ( self::PLAN_DATA as $class => $details ) {
356            $features = array_merge( $features, $details['supports'] );
357            if ( in_array( $plan_slug, $details['plans'], true ) ) {
358                return array( $class, $features );
359            }
360        }
361        return array( 'free', self::PLAN_DATA['free']['supports'] );
362    }
363
364    /**
365     * Gets the minimum plan slug that supports the given feature
366     *
367     * @param string $feature The name of the feature.
368     * @return string|bool The slug for the minimum plan that supports.
369     *  the feature or false if not found
370     */
371    public static function get_minimum_plan_for_feature( $feature ) {
372        foreach ( self::PLAN_DATA as $details ) {
373            if ( in_array( $feature, $details['supports'], true ) ) {
374                return $details['plans'][0];
375            }
376        }
377        return false;
378    }
379
380    /**
381     * Determine whether the active plan supports a particular feature
382     *
383     * @uses self::get()
384     *
385     * @access public
386     * @static
387     *
388     * @param string $feature The module or feature to check.
389     * @param bool   $refresh_from_wpcom Refresh the local plan cache from wpcom.
390     *
391     * @return bool True if plan supports feature, false if not
392     */
393    public static function supports( $feature, $refresh_from_wpcom = false ) {
394        if ( $refresh_from_wpcom ) {
395            self::refresh_from_wpcom();
396        }
397
398        // Hijack the feature eligibility check on WordPress.com sites since they are gated differently.
399        $should_wpcom_gate_feature = (
400            function_exists( 'wpcom_site_has_feature' ) &&
401            function_exists( 'wpcom_feature_exists' ) &&
402            wpcom_feature_exists( $feature )
403        );
404        if ( $should_wpcom_gate_feature ) {
405            return wpcom_site_has_feature( $feature );
406        }
407
408        // Search product bypasses plan feature check.
409        if ( 'search' === $feature && (bool) get_option( 'has_jetpack_search_product' ) ) {
410            return true;
411        }
412
413        // As of Q3 2021 - a videopress free tier is available to all plans.
414        if ( 'videopress' === $feature ) {
415            return true;
416        }
417
418        // As of 05 2023 - all plans support Earn features (minus 'simple-payments').
419        if ( in_array( $feature, array( 'donations', 'recurring-payments', 'premium-content/container' ), true ) ) {
420            return true;
421        }
422
423        $plan = self::get();
424
425        if (
426            in_array( $feature, $plan['supports'], true )
427            || in_array( $feature, $plan['features']['active'], true )
428        ) {
429            return true;
430        }
431
432        return false;
433    }
434
435    /**
436     * Retrieve site-specific features for Simple sites.
437     *
438     * See Jetpack_Gutenberg::get_site_specific_features()
439     *
440     * @return array
441     */
442    public static function get_simple_site_specific_features() {
443        $is_simple_site = defined( 'IS_WPCOM' ) && constant( 'IS_WPCOM' );
444
445        if ( ! $is_simple_site ) {
446            return array(
447                'active'    => array(),
448                'available' => array(),
449            );
450        }
451
452        $current_blog_id = get_current_blog_id();
453
454        // Return the cached value if it exists.
455        if ( isset( self::$simple_site_specific_features[ $current_blog_id ] ) ) {
456            return self::$simple_site_specific_features[ $current_blog_id ];
457        }
458
459        if ( ! class_exists( '\Store_Product_List' ) ) {
460            require WP_CONTENT_DIR . '/admin-plugins/wpcom-billing/store-product-list.php';
461        }
462
463        $simple_site_specific_features = \Store_Product_List::get_site_specific_features_data( $current_blog_id );
464
465        self::$simple_site_specific_features[ $current_blog_id ] = $simple_site_specific_features;
466
467        return $simple_site_specific_features;
468    }
469}