Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.75% covered (success)
90.75%
157 / 173
66.67% covered (warning)
66.67%
10 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Wpcom_Products
90.75% covered (success)
90.75%
157 / 173
66.67% covered (warning)
66.67%
10 / 15
57.39
0.00% covered (danger)
0.00%
0 / 1
 get_products_from_wpcom
88.00% covered (warning)
88.00%
44 / 50
0.00% covered (danger)
0.00%
0 / 1
9.14
 build_check_hash
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
9.54
 update_cache
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 is_cache_old
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 get_products_from_cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_products
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 get_product
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_product_pricing
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 populate_with_discount
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 get_site_current_purchases
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
6
 get_site_current_plan
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 reset_request_failures
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reset_purchases_cache
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 set_request_failure
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_request_failure
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Fetches and store the list of Jetpack products available in WPCOM
4 *
5 * @package automattic/my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Current_Plan;
13use Automattic\Jetpack\Status\Visitor;
14use Jetpack_Options;
15use WP_Error;
16/**
17 * Stores the list of products available for purchase in WPCOM
18 */
19class Wpcom_Products {
20
21    /**
22     * The meta name used to store the cache date
23     *
24     * @var string
25     */
26    const CACHE_DATE_META_NAME = 'my-jetpack-cache-date';
27
28    /**
29     * The meta name used to store the cache
30     *
31     * @var string
32     */
33    const CACHE_META_NAME = 'my-jetpack-cache';
34
35    const CACHE_CHECK_HASH_NAME = 'my-jetpack-wpcom-product-check-hash';
36
37    const MY_JETPACK_PURCHASES_TRANSIENT_KEY = 'my-jetpack-purchases';
38
39    /**
40     * How long, in seconds, a purchases lookup is reused before WPCOM is asked again
41     *
42     * @var int
43     */
44    const MY_JETPACK_PURCHASES_CACHE_DURATION = 5;
45
46    const MY_JETPACK_PLAN_REFRESHED_TRANSIENT_KEY = 'my-jetpack-plan-refreshed';
47
48    /**
49     * How long, in seconds, a plan refresh from WPCOM is reused before a reload asks again
50     *
51     * @var int
52     */
53    const MY_JETPACK_PLAN_REFRESH_CACHE_DURATION = 15;
54
55    /**
56     * How long, in seconds, a failed WPCOM request answers later callers before it is retried
57     *
58     * @var int
59     */
60    const WPCOM_REQUEST_FAILURE_CACHE_DURATION = 5;
61
62    /**
63     * The request label the site purchases lookup records its failures under
64     *
65     * @var string
66     */
67    const PURCHASES_REQUEST_LABEL = 'get_site_current_purchases';
68
69    /**
70     * Store the data on failed WPCOM requests, each with the time it stops answering.
71     *
72     * @var array
73     */
74    private static $wpcom_request_failures = array();
75
76    /**
77     * A successful purchases lookup, kept so one WPCOM request serves a whole render
78     * even when the transient write does not retain (e.g. an object cache evicting under load)
79     *
80     * @var mixed
81     */
82    private static $site_purchases = null;
83
84    /**
85     * Unix time at which the memo above stops being used
86     *
87     * @var int
88     */
89    private static $site_purchases_expires = 0;
90
91    /**
92     * Fetches the list of products from WPCOM
93     *
94     * @return Object|WP_Error
95     */
96    private static function get_products_from_wpcom() {
97        $connection = new Connection_Manager();
98        $blog_id    = \Jetpack_Options::get_option( 'id' );
99        $ip         = ( new Visitor() )->get_ip( true );
100        $headers    = array(
101            'X-Forwarded-For' => $ip,
102        );
103
104        if ( $blog_id ) {
105            $request_label   = 'get_products_from_wpcom_blog_' . $blog_id;
106            $request_failure = static::get_request_failure( $request_label );
107            if ( null !== $request_failure ) {
108                return $request_failure;
109            }
110
111            // If has a blog id, use connected endpoint.
112            $endpoint = sprintf( '/sites/%d/products/?_locale=%s&type=jetpack', $blog_id, get_user_locale() );
113
114            // If available in the user data, set the user's currency as one of the params
115            if ( $connection->is_user_connected() ) {
116                $user_details = $connection->get_connected_user_data();
117                if ( ! empty( $user_details['user_currency'] ) && $user_details['user_currency'] !== 'USD' ) {
118                    $endpoint .= sprintf( '&currency=%s', $user_details['user_currency'] );
119                }
120            }
121
122            $wpcom_request = Client::wpcom_json_api_request_as_blog(
123                $endpoint,
124                '1.1',
125                array(
126                    'method'  => 'GET',
127                    'headers' => $headers,
128                )
129            );
130        } else {
131            $request_label   = 'get_products_from_wpcom';
132            $request_failure = static::get_request_failure( $request_label );
133            if ( null !== $request_failure ) {
134                return $request_failure;
135            }
136
137            $endpoint = 'https://public-api.wordpress.com/rest/v1.1/products?locale=' . get_user_locale() . '&type=jetpack';
138
139            $wpcom_request = wp_remote_get(
140                esc_url_raw( $endpoint ),
141                array(
142                    'headers' => $headers,
143                )
144            );
145        }
146
147        $response_code = wp_remote_retrieve_response_code( $wpcom_request );
148
149        if ( 200 === $response_code ) {
150            $products = json_decode( wp_remote_retrieve_body( $wpcom_request ) );
151            if ( is_object( $products ) ) {
152                return $products;
153            }
154            $error = new WP_Error( 'invalid_wpcom_products', __( 'Unable to read the products list from WordPress.com', 'jetpack-my-jetpack' ) );
155            static::set_request_failure( $request_label, $error );
156            return $error;
157        } else {
158            $error = new WP_Error(
159                'failed_to_fetch_wpcom_products',
160                esc_html__( 'Unable to fetch the products list from WordPress.com', 'jetpack-my-jetpack' ),
161                array( 'status' => $response_code )
162            );
163            static::set_request_failure( $request_label, $error );
164            return $error;
165        }
166    }
167
168    /**
169     * Super unintelligent hash string that can help us reset the cache after connection changes
170     * This is important because the currency can change after a user connects depending on what is set in their profile
171     *
172     * @return string
173     */
174    private static function build_check_hash() {
175        static $has_user_data_fetch_error = false;
176
177        $hash_string = 'check_hash_' . Jetpack_Options::get_option( 'id' ) . '_' . get_user_locale();
178        $connection  = new Connection_Manager();
179
180        if ( $connection->is_connected() ) {
181            $hash_string .= 'site_connected_';
182        }
183
184        if ( $connection->is_user_connected() ) {
185            $hash_string .= 'user_connected';
186            // Add the user's currency
187            $user_details = $has_user_data_fetch_error ? false : $connection->get_connected_user_data();
188
189            if ( $user_details === false ) {
190                $has_user_data_fetch_error = true;
191            } elseif ( ! empty( $user_details['user_currency'] ) ) {
192                $hash_string .= '_' . $user_details['user_currency'];
193            }
194        }
195
196        return md5( $hash_string );
197    }
198
199    /**
200     * Update the cache with new information retrieved from WPCOM
201     *
202     * We store one cache for each user, as the information is internationalized based on user preferences
203     * Also, the currency is based on the user IP address
204     *
205     * @param Object $products_list The products list as received from WPCOM.
206     * @return bool
207     */
208    private static function update_cache( $products_list ) {
209        update_user_meta( get_current_user_id(), self::CACHE_DATE_META_NAME, time() );
210        update_user_meta( get_current_user_id(), self::CACHE_CHECK_HASH_NAME, self::build_check_hash() );
211        return update_user_meta( get_current_user_id(), self::CACHE_META_NAME, $products_list );
212    }
213
214    /**
215     * Checks if the cache is old, meaning we need to fetch new data from WPCOM
216     */
217    private static function is_cache_old() {
218        if ( empty( self::get_products_from_cache() ) ) {
219            return true;
220        }
221
222        // This allows the cache to reset after the site or user connects/ disconnects
223        $check_hash = get_user_meta( get_current_user_id(), self::CACHE_CHECK_HASH_NAME, true );
224        if ( $check_hash !== self::build_check_hash() ) {
225            return true;
226        }
227
228        $cache_date = get_user_meta( get_current_user_id(), self::CACHE_DATE_META_NAME, true );
229        return time() - (int) $cache_date > 5 * MINUTE_IN_SECONDS;
230    }
231
232    /**
233     * Gets the product list from the user cache
234     */
235    private static function get_products_from_cache() {
236        return get_user_meta( get_current_user_id(), self::CACHE_META_NAME, true );
237    }
238
239    /**
240     * Gets the product list
241     *
242     * Attempts to retrieve the products list from the user cache if cache is not too old.
243     * If cache is old, it will attempt to fetch information from WPCOM. If it fails, we return what we have in cache, if anything, otherwise we return an error.
244     *
245     * @param bool $skip_cache If true it will ignore the cache and attempt to fetch fresh information from WPCOM.
246     *
247     * @return Object|WP_Error
248     */
249    public static function get_products( $skip_cache = false ) {
250        // This is only available for logged in users.
251        if ( ! get_current_user_id() ) {
252            return null;
253        }
254        if ( ! self::is_cache_old() && ! $skip_cache ) {
255            return self::get_products_from_cache();
256        }
257
258        $products = self::get_products_from_wpcom();
259        if ( is_wp_error( $products ) ) {
260            // Let's see if we have it cached.
261            $cached = self::get_products_from_cache();
262            if ( is_object( $cached ) ) {
263                // Pro requires confirmed availability; existing offers retain their cached behavior.
264                $cached = clone $cached;
265                foreach ( $cached as $slug => $product ) {
266                    if ( str_starts_with( $slug, 'jetpack_pro_' ) && is_object( $product ) ) {
267                        $cached->$slug            = clone $product;
268                        $cached->$slug->available = false;
269                    }
270                }
271                return $cached;
272            } else {
273                return $products;
274            }
275        }
276
277        self::update_cache( $products );
278        return $products;
279    }
280
281    /**
282     * Get one product
283     *
284     * @param string $product_slug The product slug.
285     * @param bool   $renew_cache A flag to force the cache to be renewed.
286     *
287     * @return ?Object The product details if found
288     */
289    public static function get_product( $product_slug, $renew_cache = false ) {
290        $products = self::get_products( $renew_cache );
291        if ( ! empty( $products->$product_slug ) ) {
292            return $products->$product_slug;
293        }
294    }
295
296    /**
297     * Get only the product currency code and price in an array
298     *
299     * @param string $product_slug The product slug.
300     *
301     * @return array An array with currency_code and full_price. Empty array if product not found.
302     */
303    public static function get_product_pricing( $product_slug ) {
304        $product = self::get_product( $product_slug );
305        if ( empty( $product ) ) {
306            return array();
307        }
308
309        $cost                  = $product->cost;
310        $discount_price        = $cost;
311        $is_introductory_offer = false;
312        $introductory_offer    = null;
313
314        // Get/compute the discounted price.
315        if ( isset( $product->introductory_offer->cost_per_interval ) ) {
316            $discount_price        = $product->introductory_offer->cost_per_interval;
317            $is_introductory_offer = true;
318            $introductory_offer    = $product->introductory_offer;
319        }
320
321        $pricing = array(
322            'currency_code'         => $product->currency_code,
323            'full_price'            => $cost,
324            'discount_price'        => $discount_price,
325            'is_introductory_offer' => $is_introductory_offer,
326            'introductory_offer'    => $introductory_offer,
327            'product_term'          => $product->product_term,
328        );
329
330        return self::populate_with_discount( $product, $pricing, $discount_price );
331    }
332
333    /**
334     * Populate the pricing array with the discount information.
335     *
336     * @param object $product - The product object.
337     * @param array  $pricing - The pricing array.
338     * @param float  $price   - The price to be discounted.
339     * @return array The pricing array with the discount information.
340     */
341    public static function populate_with_discount( $product, $pricing, $price ) {
342        // Check whether the product has a coupon.
343        if ( ! isset( $product->sale_coupon ) ) {
344            return $pricing;
345        }
346
347        // Check whether it is still valid.
348        $coupon            = $product->sale_coupon;
349        $coupon_start_date = strtotime( $coupon->start_date );
350        $coupon_expires    = strtotime( $coupon->expires );
351        if ( $coupon_start_date > time() || $coupon_expires < time() ) {
352            return $pricing;
353        }
354
355        $coupon_discount = intval( $coupon->discount );
356
357        // Populate response with coupon discount.
358        $pricing['coupon_discount'] = $coupon_discount;
359
360        // Apply coupon discount to the price.
361        $pricing['discount_price'] = $price * ( 100 - $coupon_discount ) / 100;
362
363        return $pricing;
364    }
365
366    /**
367     * Gets the site purchases from WPCOM.
368     *
369     * @return Object|WP_Error
370     */
371    public static function get_site_current_purchases() {
372        // Read the cache first, so a memo can never outrank a warm lookup.
373        $stored_purchases = get_transient( self::MY_JETPACK_PURCHASES_TRANSIENT_KEY );
374        if ( $stored_purchases !== false ) {
375            return $stored_purchases;
376        }
377
378        /*
379         * Checked after the transient so it can never outrank a warm cache, but kept so a
380         * dashboard render still makes a single WPCOM request when the transient write is dropped.
381         */
382        if ( self::$site_purchases !== null && time() < self::$site_purchases_expires ) {
383            return self::$site_purchases;
384        }
385
386        $request_failure = static::get_request_failure( self::PURCHASES_REQUEST_LABEL );
387        if ( null !== $request_failure ) {
388            return $request_failure;
389        }
390
391        $site_id = Jetpack_Options::get_option( 'id' );
392
393        $response = Client::wpcom_json_api_request_as_blog(
394            sprintf( '/upgrades?site=%d', $site_id ),
395            '1.2',
396            array(
397                'method' => 'GET',
398            )
399        );
400        if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
401            $error = new WP_Error( 'purchases_state_fetch_failed' );
402            static::set_request_failure( self::PURCHASES_REQUEST_LABEL, $error );
403            return $error;
404        }
405
406        $body      = wp_remote_retrieve_body( $response );
407        $purchases = json_decode( $body );
408        // Set short transient to help with repeated lookups on the same page load
409        set_transient( self::MY_JETPACK_PURCHASES_TRANSIENT_KEY, $purchases, self::MY_JETPACK_PURCHASES_CACHE_DURATION );
410
411        self::$site_purchases         = $purchases;
412        self::$site_purchases_expires = time() + self::MY_JETPACK_PURCHASES_CACHE_DURATION;
413
414        return $purchases;
415    }
416
417    /**
418     * Gets the site's currently active "plan" (bundle).
419     *
420     * @param bool $reload  Whether to refresh data from wpcom or not.
421     * @return array
422     */
423    public static function get_site_current_plan( $reload = false ) {
424        static $reloaded_already = false;
425
426        // The page render and the REST requests it fires all reload, so share one refresh between them.
427        if ( $reload && ! $reloaded_already ) {
428            // Without a site ID nothing can refresh, so don't start the window until one exists.
429            if ( ! get_transient( self::MY_JETPACK_PLAN_REFRESHED_TRANSIENT_KEY ) && Connection_Manager::get_site_id( true ) ) {
430                Current_Plan::refresh_from_wpcom();
431                set_transient( self::MY_JETPACK_PLAN_REFRESHED_TRANSIENT_KEY, 1, self::MY_JETPACK_PLAN_REFRESH_CACHE_DURATION );
432            }
433            $reloaded_already = true;
434        }
435
436        return Current_Plan::get();
437    }
438
439    /**
440     * Reset the request failures to retry the API requests.
441     *
442     * @return void
443     */
444    public static function reset_request_failures() {
445        static::$wpcom_request_failures = array();
446    }
447
448    /**
449     * Forget the cached site purchases â€” the in-process memo, the memoized failure, and the
450     * transient â€” so the next call asks WPCOM again.
451     *
452     * @return void
453     */
454    public static function reset_purchases_cache() {
455        self::$site_purchases         = null;
456        self::$site_purchases_expires = 0;
457        unset( static::$wpcom_request_failures[ self::PURCHASES_REQUEST_LABEL ] );
458        delete_transient( self::MY_JETPACK_PURCHASES_TRANSIENT_KEY );
459    }
460
461    /**
462     * Record the request failure to prevent repeated requests.
463     *
464     * @param string   $request_label The request label.
465     * @param WP_Error $error The error.
466     *
467     * @return void
468     */
469    private static function set_request_failure( $request_label, WP_Error $error ) {
470        static::$wpcom_request_failures[ $request_label ] = array(
471            'error'   => $error,
472            'expires' => time() + self::WPCOM_REQUEST_FAILURE_CACHE_DURATION,
473        );
474    }
475
476    /**
477     * Get the pre-saved request failure if exists.
478     *
479     * @param string $request_label The request label.
480     *
481     * @return null|WP_Error
482     */
483    private static function get_request_failure( $request_label ) {
484        if ( ! isset( static::$wpcom_request_failures[ $request_label ] ) ) {
485            return null;
486        }
487
488        // Expire rather than answer for the life of the process, which can outlast the outage.
489        if ( time() >= static::$wpcom_request_failures[ $request_label ]['expires'] ) {
490            unset( static::$wpcom_request_failures[ $request_label ] );
491            return null;
492        }
493
494        return static::$wpcom_request_failures[ $request_label ]['error'];
495    }
496}