Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.43% covered (success)
93.43%
185 / 198
80.00% covered (warning)
80.00%
20 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Search
93.88% covered (success)
93.88%
184 / 196
80.00% covered (warning)
80.00%
20 / 25
72.16
0.00% covered (danger)
0.00%
0 / 1
 get_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_description
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_long_description
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_features
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_pricing_for_ui
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 get_post_checkout_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_wpcom_product_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_wpcom_free_product_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_new_pricing_202208
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 get_status
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_pricing_from_wpcom
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
8.08
 has_trial_support
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_paid_plan_product_slugs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 has_free_plan_for_product
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
7.90
 activate_free_product
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
1 / 1
13
 wpcom_activation_error
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 error_status
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 activation_error
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 sync_local_state_after_free_activation
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 set_default_experience_if_unset
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 do_product_specific_activation
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
 get_post_activation_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_manage_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_upgradable_by_bundle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Search product
4 *
5 * @package my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack\Products;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Constants;
13use Automattic\Jetpack\My_Jetpack\Hybrid_Product;
14use Automattic\Jetpack\My_Jetpack\Wpcom_Products;
15use Automattic\Jetpack\Search\Module_Control as Search_Module_Control;
16use Automattic\Jetpack\Status\Host;
17use WP_Error;
18
19if ( ! defined( 'ABSPATH' ) ) {
20    exit( 0 );
21}
22
23/**
24 * Class responsible for handling the Search product
25 */
26class Search extends Hybrid_Product {
27    /**
28     * Fallback starting price (USD, billed yearly) for the entry record tier, used when
29     * the WPCOM pricing fetch fails so the dashboard still shows a price, not "$0".
30     *
31     * @var float
32     */
33    const FALLBACK_STARTING_PRICE_USD = 100;
34
35    /**
36     * Search "new pricing" version identifier (introduced 202208).
37     *
38     * Intentionally duplicated from Automattic\Jetpack\Search\Plan::JETPACK_SEARCH_NEW_PRICING_VERSION
39     * rather than referencing that class. My Jetpack is bundled into standalone plugins (e.g. Jetpack
40     * Boost) that do NOT ship the jetpack-search package, so referencing Search\Plan here fatals with
41     * "Class not found" the moment this product builds its pricing data (introduced by PR #48892).
42     *
43     * @var string
44     */
45    const SEARCH_NEW_PRICING_VERSION = '202208';
46
47    /**
48     * The product slug
49     *
50     * @var string
51     */
52    public static $slug = 'search';
53
54    /**
55     * The Jetpack module name
56     *
57     * @var string
58     */
59    public static $module_name = 'search';
60
61    /**
62     * The slug of the plugin associated with this product.
63     *
64     * @var string
65     */
66    public static $plugin_slug = 'jetpack-search';
67
68    /**
69     * The category of the product
70     *
71     * @var string
72     */
73    public static $category = 'performance';
74
75    /**
76     * Search has a standalone plugin
77     *
78     * @var bool
79     */
80    public static $has_standalone_plugin = true;
81
82    /**
83     * Whether this product has a free offering
84     *
85     * @var bool
86     */
87    public static $has_free_offering = true;
88
89    /**
90     * Whether this product requires a plan to work at all
91     *
92     * @var bool
93     */
94    public static $requires_plan = true;
95
96    /**
97     * The filename (id) of the plugin associated with this product.
98     *
99     * @var string
100     */
101    public static $plugin_filename = array(
102        'jetpack-search/jetpack-search.php',
103        'search/jetpack-search.php',
104        'jetpack-search-dev/jetpack-search.php',
105    );
106
107    /**
108     * Search only requires site connection
109     *
110     * @var boolean
111     */
112    public static $requires_user_connection = true;
113
114    /**
115     * The feature slug that identifies the paid plan
116     *
117     * @var string
118     */
119    public static $feature_identifying_paid_plan = 'search';
120
121    /**
122     * Get the product name
123     *
124     * @return string
125     */
126    public static function get_name() {
127        return 'Search';
128    }
129
130    /**
131     * Get the product title
132     *
133     * @return string
134     */
135    public static function get_title() {
136        return 'Jetpack Search';
137    }
138
139    /**
140     * Get the internationalized product description
141     *
142     * @return string
143     */
144    public static function get_description() {
145        return __( 'Instantly deliver the most relevant results to your visitors.', 'jetpack-my-jetpack' );
146    }
147
148    /**
149     * Get the internationalized product long description
150     *
151     * @return string
152     */
153    public static function get_long_description() {
154        return __( 'Help your site visitors find answers instantly so they keep reading and buying. Great for sites with a lot of content.', 'jetpack-my-jetpack' );
155    }
156
157    /**
158     * Get the internationalized features list
159     *
160     * @return array Boost features list
161     */
162    public static function get_features() {
163        return array(
164            __( 'Instant search and indexing', 'jetpack-my-jetpack' ),
165            __( 'Powerful filtering', 'jetpack-my-jetpack' ),
166            __( 'Supports 38 languages', 'jetpack-my-jetpack' ),
167            __( 'Spelling correction', 'jetpack-my-jetpack' ),
168        );
169    }
170
171    /**
172     * Get the product princing details
173     *
174     * @return array Pricing details
175     */
176    public static function get_pricing_for_ui() {
177        // Basic pricing info.
178        $pricing = array_merge(
179            array(
180                'available'               => true,
181                'trial_available'         => static::has_trial_support(),
182                'wpcom_product_slug'      => static::get_wpcom_product_slug(),
183                'wpcom_free_product_slug' => static::get_wpcom_free_product_slug(),
184            ),
185            Wpcom_Products::get_product_pricing( static::get_wpcom_product_slug() )
186        );
187
188        $record_count   = intval( Search_Stats::estimate_count() );
189        $search_pricing = static::get_pricing_from_wpcom( $record_count );
190
191        if ( is_wp_error( $search_pricing ) ) {
192            // Default to the current pricing experience when the WPCOM fetch fails so the
193            // dashboard degrades to the production default, not the legacy single-card view.
194            $pricing['pricing_version'] = self::SEARCH_NEW_PRICING_VERSION;
195
196            // If the generic product pricing was also unavailable, fall back to a USD
197            // starting price so the pricing grid renders a price instead of "$0".
198            if ( empty( $pricing['full_price'] ) ) {
199                $pricing['currency_code']  = 'USD';
200                $pricing['full_price']     = self::FALLBACK_STARTING_PRICE_USD;
201                $pricing['discount_price'] = self::FALLBACK_STARTING_PRICE_USD;
202            }
203
204            return $pricing;
205        }
206
207        $pricing['estimated_record_count'] = $record_count;
208
209        return array_merge( $pricing, $search_pricing );
210    }
211
212    /**
213     * Get the URL the user is taken after purchasing the product through the checkout
214     *
215     * @return ?string
216     */
217    public static function get_post_checkout_url() {
218        return self::get_manage_url();
219    }
220
221    /**
222     * Get the WPCOM product slug used to make the purchase
223     *
224     * @return ?string
225     */
226    public static function get_wpcom_product_slug() {
227        return 'jetpack_search';
228    }
229
230    /**
231     * Get the WPCOM free product slug
232     *
233     * @return ?string
234     */
235    public static function get_wpcom_free_product_slug() {
236        return 'jetpack_search_free';
237    }
238
239    /**
240     * Returns true if the new_pricing_202208 is set to not empty in URL for testing purpose, or it's active.
241     */
242    public static function is_new_pricing_202208() {
243        // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
244        if ( isset( $_GET['new_pricing_202208'] ) && $_GET['new_pricing_202208'] ) {
245            return true;
246        }
247
248        $record_count   = intval( Search_Stats::estimate_count() );
249        $search_pricing = static::get_pricing_from_wpcom( $record_count );
250        if ( is_wp_error( $search_pricing ) ) {
251            // Default to the current pricing experience when the WPCOM fetch fails.
252            return true;
253        }
254
255        return self::SEARCH_NEW_PRICING_VERSION === $search_pricing['pricing_version'];
256    }
257
258    /**
259     * Override status to `needs_activation` when status is `needs_plan`.
260     */
261    public static function get_status() {
262        $status = parent::get_status();
263        return $status;
264    }
265
266    /**
267     * Use centralized Search pricing API.
268     *
269     * The function is also used by the search package, as a result it could be called before site connection - i.e. blog token might not be available.
270     *
271     * @param int $record_count Record count to estimate pricing.
272     *
273     * @return array|WP_Error
274     */
275    public static function get_pricing_from_wpcom( $record_count ) {
276        static $pricings = array();
277        $connection      = new Connection_Manager();
278        $blog_id         = \Jetpack_Options::get_option( 'id' );
279
280        if ( isset( $pricings[ $record_count ] ) ) {
281            return $pricings[ $record_count ];
282        }
283
284        // If the site is connected, request pricing with the blog token
285        if ( $blog_id ) {
286            $endpoint = sprintf( '/jetpack-search/pricing?record_count=%1$d&locale=%2$s', $record_count, get_user_locale() );
287
288            // If available in the user data, set the user's currency as one of the params
289            if ( $connection->is_user_connected() ) {
290                $user_details = $connection->get_connected_user_data();
291                if ( ! empty( $user_details['user_currency'] ) && $user_details['user_currency'] !== 'USD' ) {
292                    $endpoint .= sprintf( '&currency=%s', $user_details['user_currency'] );
293                }
294            }
295
296            $response = Client::wpcom_json_api_request_as_blog(
297                $endpoint,
298                '2',
299                array( 'timeout' => 5 ),
300                null,
301                'wpcom'
302            );
303        } else {
304            $response = wp_remote_get(
305                sprintf( Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ) . '/wpcom/v2/jetpack-search/pricing?record_count=%1$d&locale=%2$s', $record_count, get_user_locale() ),
306                array( 'timeout' => 5 )
307            );
308        }
309
310        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
311            // Cache the failure too: get_pricing_for_ui() reaches this twice per request
312            // (once via has_trial_support(), once directly), and each miss is a 5s timeout.
313            $pricings[ $record_count ] = new WP_Error( 'search_pricing_fetch_failed' );
314            return $pricings[ $record_count ];
315        }
316
317        $body                      = wp_remote_retrieve_body( $response );
318        $pricings[ $record_count ] = json_decode( $body, true );
319        return $pricings[ $record_count ];
320    }
321
322    /**
323     * Checks whether the product supports trial or not
324     *
325     * Returns true if it supports. Return false otherwise.
326     *
327     * Free products will always return false.
328     *
329     * @return boolean
330     */
331    public static function has_trial_support() {
332        return static::is_new_pricing_202208();
333    }
334
335    /**
336     * Get the product-slugs of the paid plans for this product (not including bundles)
337     *
338     * @return array
339     */
340    public static function get_paid_plan_product_slugs() {
341        return array(
342            'jetpack_search',
343            'jetpack_search_monthly',
344            'jetpack_search_bi_yearly',
345        );
346    }
347
348    /**
349     * Checks if the site purchases contain a free search plan
350     *
351     * @return bool
352     */
353    public static function has_free_plan_for_product() {
354        $purchases_data = Wpcom_Products::get_site_current_purchases();
355        if ( is_wp_error( $purchases_data ) ) {
356            return false;
357        }
358        if ( is_array( $purchases_data ) && ! empty( $purchases_data ) ) {
359            foreach ( $purchases_data as $purchase ) {
360                if ( str_contains( $purchase->product_slug, 'jetpack_search_free' ) ) {
361                    return true;
362                }
363            }
364        }
365        return false;
366    }
367
368    /**
369     * Grant this site the free Search product without sending the user through a $0 checkout.
370     *
371     * Safe to call on every activate click: WordPress.com re-pushes its configuration to this
372     * site before answering `already_entitled`, which is how a half-finished activation repairs.
373     *
374     * @since 6.8.0
375     *
376     * @param string|null $source Where the activation was requested from, for reporting.
377     * @return array|WP_Error Decoded response body on success; on failure a WP_Error whose data
378     *                        carries `checkout_fallback` — true when the $0 checkout could still
379     *                        succeed, false when it would refuse the product too.
380     */
381    public static function activate_free_product( $source = null ) {
382        /*
383         * Both REST routes reach this, and their permission callbacks disagree on multisite,
384         * so the bar for creating the subscription is enforced here instead.
385         */
386        if ( ! current_user_can( 'activate_plugins' ) || ( is_multisite() && ! current_user_can( 'manage_network' ) ) ) {
387            return self::activation_error(
388                'rest_cannot_activate',
389                __( 'You are not allowed to activate Jetpack Search Free on this site.', 'jetpack-my-jetpack' ),
390                403,
391                false
392            );
393        }
394
395        /*
396         * A Simple site has no Jetpack connection to sign the request with, and buys the
397         * product from within WordPress.com anyway.
398         */
399        if ( ( new Host() )->is_wpcom_simple() ) {
400            return self::activation_error(
401                'not_supported_on_wpcom_simple',
402                __( 'Jetpack Search Free is activated through WordPress.com on this site.', 'jetpack-my-jetpack' ),
403                400,
404                true
405            );
406        }
407
408        $blog_id = \Jetpack_Options::get_option( 'id' );
409        if ( ! $blog_id ) {
410            return self::activation_error(
411                'site_not_registered',
412                __( 'Connect your site to WordPress.com to activate Jetpack Search.', 'jetpack-my-jetpack' ),
413                403,
414                true
415            );
416        }
417
418        /*
419         * Checked before the request because the subscription needs a WordPress.com user to own
420         * it and a blog token names nobody. Checkout signs the user in itself, so this is a
421         * fallback rather than a failure.
422         */
423        if ( ! ( new Connection_Manager() )->is_user_connected() ) {
424            return self::activation_error(
425                'no_connected_user',
426                __( 'Activating Jetpack Search Free requires a connected WordPress.com user.', 'jetpack-my-jetpack' ),
427                403,
428                true
429            );
430        }
431
432        // WordPress.com records an unfamiliar source as `unknown` rather than refusing, so pass
433        // through whatever the caller sent instead of dropping values this copy predates.
434        $body = array();
435        if ( is_string( $source ) && $source !== '' ) {
436            $body['source'] = sanitize_key( $source );
437        }
438
439        $response = Client::wpcom_json_api_request_as_user(
440            '/sites/' . $blog_id . '/jetpack-search/activate-free',
441            '2',
442            // Well over the 10s default: the grant re-pushes Search configuration to this site
443            // inside the request, so a timeout here can abandon a subscription already created.
444            array(
445                'method'  => 'POST',
446                'timeout' => 45,
447            ),
448            $body,
449            'wpcom'
450        );
451
452        // Never reached WordPress.com, so checkout may still work.
453        if ( is_wp_error( $response ) ) {
454            return self::activation_error(
455                'jetpack_search_free_activation_failed',
456                $response->get_error_message(),
457                500,
458                true
459            );
460        }
461
462        $status = wp_remote_retrieve_response_code( $response );
463        $result = json_decode( wp_remote_retrieve_body( $response ), true );
464
465        if ( 200 !== $status || ! is_array( $result ) || empty( $result['success'] ) ) {
466            return self::wpcom_activation_error( $result, $status );
467        }
468
469        $result['local_activation'] = self::sync_local_state_after_free_activation();
470
471        return $result;
472    }
473
474    /**
475     * Rebuild a refusal from WordPress.com as a local WP_Error.
476     *
477     * Only a body carrying `checkout_fallback` came from the endpoint. Anything else — a 404
478     * before it deploys, a proxy error page — means it was never reached, and checkout still
479     * works, so those fall back rather than dead-ending the user.
480     *
481     * @param array|null $result Decoded response body, when it parsed.
482     * @param int        $status HTTP status code.
483     * @return WP_Error
484     */
485    private static function wpcom_activation_error( $result, $status ) {
486        $data = isset( $result['data'] ) && is_array( $result['data'] ) ? $result['data'] : array();
487        $code = isset( $result['code'] ) && is_string( $result['code'] ) ? $result['code'] : '';
488
489        /*
490         * The flag, not the code, is what marks an answer as the endpoint's own: WordPress
491         * raises `rest_no_route` with a code too, and that one means checkout still works.
492         */
493        $is_endpoint_refusal = array_key_exists( 'checkout_fallback', $data );
494
495        if ( ! $is_endpoint_refusal ) {
496            /*
497             * Always 502, never the upstream status. This route exists; WordPress.com answering
498             * 404 before the endpoint deploys is not our 404 — and relaying it would have the
499             * REST client throw a bare Api404Error with no body, losing the `checkout_fallback`
500             * that sends the user to checkout in exactly that window.
501             */
502            return self::activation_error(
503                'jetpack_search_free_activation_failed',
504                __( 'Jetpack Search Free could not be activated for this site.', 'jetpack-my-jetpack' ),
505                502,
506                true
507            );
508        }
509
510        return self::activation_error(
511            $code !== '' ? $code : 'jetpack_search_free_activation_failed',
512            $result['message'] ?? __( 'Jetpack Search Free could not be activated for this site.', 'jetpack-my-jetpack' ),
513            self::error_status( $data, $status ),
514            ! empty( $data['checkout_fallback'] ),
515            $data
516        );
517    }
518
519    /**
520     * Pick an error status for an enumerated refusal, never a 2xx.
521     *
522     * A success status on a WP_Error reaches the browser as a 200, which both REST clients read
523     * as a granted product.
524     *
525     * @param array $data   Error data from WordPress.com.
526     * @param int   $status HTTP status of the response.
527     * @return int
528     */
529    private static function error_status( $data, $status ) {
530        foreach ( array( $data['status'] ?? 0, $status ) as $candidate ) {
531            if ( (int) $candidate >= 400 ) {
532                return (int) $candidate;
533            }
534        }
535
536        return 502;
537    }
538
539    /**
540     * Build a refusal carrying the `checkout_fallback` flag the UI branches on.
541     *
542     * @param string $code              Machine readable error code.
543     * @param string $message           Human readable message.
544     * @param int    $status            HTTP status.
545     * @param bool   $checkout_fallback Whether the caller should fall back to checkout.
546     * @param array  $extra_data        Extra data from WordPress.com to preserve.
547     * @return WP_Error
548     */
549    private static function activation_error( $code, $message, $status, $checkout_fallback, $extra_data = array() ) {
550        return new WP_Error(
551            $code,
552            $message,
553            array_merge(
554                $extra_data,
555                array(
556                    'status'            => $status,
557                    'checkout_fallback' => (bool) $checkout_fallback,
558                )
559            )
560        );
561    }
562
563    /**
564     * Bring this site's Search state in line after WordPress.com grants the product.
565     *
566     * WordPress.com writes the plan option from a separate request, so the copy cached in this
567     * process predates it and has to be re-read. A site already entitled through a plan or
568     * bundle gets no such request at all, and this is the only thing that turns Search on.
569     *
570     * @return string `activated`, `unavailable` when this plugin does not ship jetpack-search,
571     *                or an error code the caller can surface — re-running the activation repairs it.
572     */
573    private static function sync_local_state_after_free_activation() {
574        if ( ! class_exists( 'Automattic\Jetpack\Search\Plan' ) ) {
575            return 'unavailable';
576        }
577
578        $plan_info = ( new \Automattic\Jetpack\Search\Plan() )->get_plan_info_from_wpcom();
579        if ( is_wp_error( $plan_info ) ) {
580            return $plan_info->get_error_code();
581        }
582
583        if ( ! class_exists( 'Automattic\Jetpack\Search\Module_Control' ) ) {
584            return 'unavailable';
585        }
586
587        $module_control = new Search_Module_Control();
588        $activated      = $module_control->activate();
589
590        if ( is_wp_error( $activated ) ) {
591            return $activated->get_error_code();
592        }
593
594        self::set_default_experience_if_unset( $module_control );
595
596        return $activated ? 'activated' : 'already_active';
597    }
598
599    /**
600     * Give a site with no experience of its own the same one WordPress.com would have pushed.
601     *
602     * Only fires when nothing is stored: deactivating Search deliberately keeps the choice for
603     * the next re-activation, so a site that picked Embedded must not be flipped to Overlay.
604     *
605     * @param Search_Module_Control $module_control Module control for this site.
606     */
607    private static function set_default_experience_if_unset( $module_control ) {
608        if ( get_option( Search_Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ) ) {
609            return;
610        }
611
612        // Mirrors Jetpack_Plans_Search::DEFAULT_SEARCH_EXPERIENCE on the WordPress.com side.
613        $module_control->update_experience( Search_Module_Control::EXPERIENCE_OVERLAY );
614    }
615
616    /**
617     * Activates the product. Try to enable instant search after the Search module was enabled.
618     *
619     * @param bool|WP_Error $product_activation Is the result of the top level activation actions. You probably won't do anything if it is an WP_Error.
620     * @param bool          $local              Skip connected Search setup.
621     * @return bool|WP_Error
622     */
623    public static function do_product_specific_activation( $product_activation, $local = false ) {
624        $product_activation = parent::do_product_specific_activation( $product_activation, $local );
625        if ( is_wp_error( $product_activation ) || $local ) {
626            return $product_activation;
627        }
628
629        if ( class_exists( 'Automattic\Jetpack\Search\Module_Control' ) ) {
630            ( new Search_Module_Control() )->enable_instant_search();
631        }
632
633        // we don't want to change the success of the activation if we fail to activate instant search. That's not mandatory.
634        return $product_activation;
635    }
636
637    /**
638     * Get the URL the user is taken after activating the product
639     *
640     * @return ?string
641     */
642    public static function get_post_activation_url() {
643        return ''; // stay in My Jetpack page or continue the purchase flow if needed.
644    }
645
646    /**
647     * Get the URL where the user manages the product
648     *
649     * @return ?string
650     */
651    public static function get_manage_url() {
652        return admin_url( 'admin.php?page=jetpack-search' );
653    }
654
655    /**
656     * Return product bundles list
657     * that supports the product.
658     *
659     * @return boolean|array Products bundle list.
660     */
661    public static function is_upgradable_by_bundle() {
662        return array( 'complete' );
663    }
664}