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