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