Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.03% covered (success)
98.03%
199 / 203
85.71% covered (warning)
85.71%
12 / 14
CRAP
n/a
0 / 0
Automattic\Jetpack\PremiumAnalytics\get_stats_plan_tier
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
Automattic\Jetpack\PremiumAnalytics\get_wpcom_stats_plan_tier
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
Automattic\Jetpack\PremiumAnalytics\get_jetpack_stats_plan_tier
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\PremiumAnalytics\has_cached_jetpack_plan
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
Automattic\Jetpack\PremiumAnalytics\has_jetpack_stats_commercial_purchase
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
Automattic\Jetpack\PremiumAnalytics\get_locked_stats_features
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
Automattic\Jetpack\PremiumAnalytics\get_stats_feature_copy
100.00% covered (success)
100.00%
65 / 65
100.00% covered (success)
100.00%
1 / 1
14
Automattic\Jetpack\PremiumAnalytics\localize_stats_support_url
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
Automattic\Jetpack\PremiumAnalytics\get_stats_upgrade_url
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
Automattic\Jetpack\PremiumAnalytics\get_stats_plan_gates
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
Automattic\Jetpack\PremiumAnalytics\configure_stats_plan_gates
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
Automattic\Jetpack\PremiumAnalytics\inject_stats_plan_gates_script_data
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
Automattic\Jetpack\PremiumAnalytics\maybe_invalidate_stats_caches
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
Automattic\Jetpack\PremiumAnalytics\maybe_refresh_jetpack_plan
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2/**
3 * Which paid Stats features the site's plan lacks, following classic Stats' gates.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics;
9
10use Automattic\Jetpack\Current_Plan;
11use Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller;
12use Automattic\Jetpack\Stats\Options as Stats_Options;
13use Automattic\Jetpack\Status;
14use Automattic\Jetpack\Status\Host;
15
16// Guarded on a symbol the file declares; see the include block in Analytics::load_dashboard_components().
17if ( ! function_exists( __NAMESPACE__ . '\\ensure_widget_registry_ready' ) ) {
18    require_once __DIR__ . '/widget-modules.php';
19}
20
21/**
22 * Filter overriding the Stats plan tier.
23 *
24 * @var string
25 */
26const STATS_PLAN_TIER_FILTER = 'jetpack_premium_analytics_stats_plan_tier';
27
28const STATS_TIER_COMMERCIAL   = 'commercial';
29const STATS_TIER_LEGACY_FREE  = 'legacy-free';
30const STATS_TIER_PAID         = 'paid';
31const STATS_TIER_LEGACY_BASIC = 'legacy-basic';
32const STATS_TIER_FREE         = 'free';
33const STATS_TIER_JETPACK_FREE = 'jetpack-free';
34const STATS_TIER_UNKNOWN      = 'unknown';
35
36const STATS_TIERS = array( STATS_TIER_COMMERCIAL, STATS_TIER_LEGACY_FREE, STATS_TIER_PAID, STATS_TIER_LEGACY_BASIC, STATS_TIER_FREE, STATS_TIER_JETPACK_FREE, STATS_TIER_UNKNOWN );
37
38/**
39 * Jetpack products that support commercial use. PWYW and Free Stats do not.
40 */
41const JETPACK_STATS_COMMERCIAL_PRODUCTS = array( 'jetpack_stats_monthly', 'jetpack_stats_yearly', 'jetpack_stats_bi_yearly' );
42
43/**
44 * Jetpack plan families that bundle commercial Stats.
45 */
46const JETPACK_STATS_COMMERCIAL_PLAN_PREFIXES = array( 'jetpack_business', 'jetpack_complete', 'jetpack_growth' );
47
48/**
49 * The Stats plan tier the dashboard is gated by.
50 *
51 * @since $$next-version$$
52 *
53 * @return string One of the STATS_TIER_* values.
54 */
55function get_stats_plan_tier() {
56    $host = new Host();
57
58    if ( $host->is_wpcom_platform() ) {
59        $tier = get_wpcom_stats_plan_tier( $host->is_woa_site() );
60    } elseif ( $host->is_vip_site() ) {
61        $tier = STATS_TIER_COMMERCIAL;
62    } else {
63        $tier = get_jetpack_stats_plan_tier();
64    }
65
66    /**
67     * Filters the Stats plan tier Premium Analytics gates the dashboard by.
68     *
69     * @since $$next-version$$
70     *
71     * @param string $tier One of `commercial`, `legacy-free`, `paid`, `legacy-basic`, `free`, `jetpack-free` or
72     *                     `unknown`, which locks nothing. Any other value is ignored.
73     */
74    $filtered = apply_filters( STATS_PLAN_TIER_FILTER, $tier );
75
76    // A misspelled tier would otherwise fall through to `unknown` and unlock every paid feature.
77    if ( ! in_array( $filtered, STATS_TIERS, true ) ) {
78        // One line: tools/replace-next-version-tag.sh only rewrites the token in a single-line call.
79        _doing_it_wrong( __FUNCTION__, esc_html( sprintf( /* translators: %s: Filter name. */ __( 'The %s filter must return one of the STATS_TIER_* values.', 'jetpack-premium-analytics-pkg' ), STATS_PLAN_TIER_FILTER ) ), 'jetpack-premium-analytics-$$next-version$$' );
80        return $tier;
81    }
82
83    return $filtered;
84}
85
86/**
87 * The tier of a WordPress.com site, from its stats feature.
88 *
89 * @since $$next-version$$
90 *
91 * @param bool $is_atomic Whether the site is Atomic.
92 * @return string One of the STATS_TIER_* values.
93 */
94function get_wpcom_stats_plan_tier( $is_atomic ) {
95    if ( ! function_exists( 'wpcom_site_has_feature' ) ) {
96        return STATS_TIER_UNKNOWN;
97    }
98
99    // Unsynced Atomic data reads like a site that bought nothing; see Current_Plan::get_wpcom_site_specific_features().
100    if ( $is_atomic && function_exists( 'wpcom_get_site_purchases' ) && ! \wpcom_get_site_purchases() ) {
101        return STATS_TIER_UNKNOWN;
102    }
103
104    // Calypso's shouldGateStats() order: legacy free outranks paid.
105    $tiers = array(
106        'stats-commercial' => STATS_TIER_COMMERCIAL,
107        'stats-free'       => STATS_TIER_LEGACY_FREE,
108        'stats-paid'       => STATS_TIER_PAID,
109        'stats-basic'      => STATS_TIER_LEGACY_BASIC,
110    );
111    foreach ( $tiers as $feature => $tier ) {
112        if ( \wpcom_site_has_feature( $feature ) ) {
113            return $tier;
114        }
115    }
116
117    return STATS_TIER_FREE;
118}
119
120/**
121 * The tier of a self-hosted site, from its cached plan.
122 *
123 * @since $$next-version$$
124 *
125 * @return string One of the STATS_TIER_* values.
126 */
127function get_jetpack_stats_plan_tier() {
128    if ( ! has_cached_jetpack_plan() ) {
129        return STATS_TIER_UNKNOWN;
130    }
131
132    return has_jetpack_stats_commercial_purchase() ? STATS_TIER_COMMERCIAL : STATS_TIER_JETPACK_FREE;
133}
134
135/**
136 * Whether the plan cache holds a plan. An empty one cannot tell a paid site from a free one (STATS-475).
137 *
138 * @since $$next-version$$
139 *
140 * @return bool
141 */
142function has_cached_jetpack_plan() {
143    $plan = get_option( Current_Plan::PLAN_OPTION );
144
145    return is_array( $plan ) && is_array( $plan['features']['active'] ?? null ) && $plan['features']['active'];
146}
147
148/**
149 * Whether a self-hosted site's cached plan or products support commercial use.
150 *
151 * @since $$next-version$$
152 *
153 * @return bool
154 */
155function has_jetpack_stats_commercial_purchase() {
156    $plan     = get_option( Current_Plan::PLAN_OPTION );
157    $products = get_option( Current_Plan::SITE_PRODUCTS_OPTION, array() );
158    $products = is_array( $products ) ? $products : array();
159
160    $slugs = array_column( $products, 'product_slug' );
161    if ( isset( $plan['product_slug'] ) ) {
162        $slugs[] = $plan['product_slug'];
163    }
164
165    foreach ( $slugs as $slug ) {
166        if ( in_array( $slug, JETPACK_STATS_COMMERCIAL_PRODUCTS, true ) ) {
167            return true;
168        }
169        foreach ( JETPACK_STATS_COMMERCIAL_PLAN_PREFIXES as $prefix ) {
170            if ( 0 === strpos( $slug, $prefix ) ) {
171                return true;
172            }
173        }
174    }
175
176    return in_array( 'stats-commercial', $plan['features']['active'] ?? array(), true );
177}
178
179/**
180 * The features classic Stats gates on WordPress.com Personal: the newer, commercial modules.
181 */
182const STATS_COMMERCIAL_FEATURES = array( 'authors', 'search-terms', 'videos', 'utm', 'devices', 'regions-cities' );
183
184/**
185 * The features gated on legacy-free sites and on self-hosted sites without a commercial purchase.
186 */
187const STATS_ADVANCED_FEATURES = array( 'utm', 'devices', 'regions-cities' );
188
189/**
190 * The features a tier locks.
191 *
192 * @since $$next-version$$
193 *
194 * @param string $tier One of the STATS_TIER_* values.
195 * @return string[] Feature keys.
196 */
197function get_locked_stats_features( $tier ) {
198    switch ( $tier ) {
199        case STATS_TIER_LEGACY_FREE:
200        case STATS_TIER_JETPACK_FREE:
201            return STATS_ADVANCED_FEATURES;
202        case STATS_TIER_PAID:
203            return STATS_COMMERCIAL_FEATURES;
204        case STATS_TIER_LEGACY_BASIC:
205            return array_merge( STATS_COMMERCIAL_FEATURES, array( 'referrers', 'clicks', 'csv', 'intervals', 'long-date-presets' ) );
206        case STATS_TIER_FREE:
207            return array_merge(
208                STATS_COMMERCIAL_FEATURES,
209                array( 'top-posts', 'referrers', 'countries', 'clicks', 'file-downloads', 'insights', 'tags', 'csv', 'intervals', 'long-date-presets', 'recent-7-days' )
210            );
211        default:
212            return array();
213    }
214}
215
216/**
217 * What a locked feature says, worded as classic Stats' upsell; `<link>` wraps the words its support page explains.
218 *
219 * @since $$next-version$$
220 *
221 * @param string $feature Feature key.
222 * @return array{description:string, support_url:string} The copy, generic for a feature without its own.
223 */
224function get_stats_feature_copy( $feature ) {
225    $traffic_support = 'https://jetpack.com/support/jetpack-stats/traffic-dashboard/';
226
227    switch ( $feature ) {
228        case 'top-posts':
229            return array(
230                'description' => __( 'Discover your <link>post and pages</link> traffic in detail and learn what content resonates the most.', 'jetpack-premium-analytics-pkg' ),
231                'support_url' => $traffic_support . '#analyzing-popular-posts-pages-and-archives',
232            );
233        case 'referrers':
234            return array(
235                'description' => __( 'Find out where your <link>visitors come from</link> to optimize your content strategy.', 'jetpack-premium-analytics-pkg' ),
236                'support_url' => $traffic_support . '#referrers',
237            );
238        case 'countries':
239            return array(
240                'description' => __( 'Discover where your <link>visitors are located</link> and identify where your traffic is coming from.', 'jetpack-premium-analytics-pkg' ),
241                'support_url' => $traffic_support . '#views-by-locations',
242            );
243        case 'regions-cities':
244            return array(
245                'description' => __( 'Access to <link>region and city stats</link> for more accurate analysis.', 'jetpack-premium-analytics-pkg' ),
246                'support_url' => $traffic_support . '#regions-and-cities',
247            );
248        case 'clicks':
249            return array(
250                'description' => __( 'Learn what <link>external links</link> your visitors click on your site to reveal their areas of interest.', 'jetpack-premium-analytics-pkg' ),
251                'support_url' => $traffic_support . '#clicks',
252            );
253        case 'file-downloads':
254            return array(
255                'description' => __( 'Discover the most <link>downloaded files</link> by your visitors.', 'jetpack-premium-analytics-pkg' ),
256                'support_url' => 'https://wordpress.com/support/stats/analyze-content-performance/#track-file-downloads',
257            );
258        case 'authors':
259            return array(
260                'description' => __( 'Identify your audience’s <link>favorite writers</link> and perspectives.', 'jetpack-premium-analytics-pkg' ),
261                'support_url' => $traffic_support . '#authors',
262            );
263        case 'search-terms':
264            return array(
265                'description' => __( 'Discover the <link>terms and phrases</link> your visitors use to find your site.', 'jetpack-premium-analytics-pkg' ),
266                'support_url' => $traffic_support . '#search-terms',
267            );
268        case 'videos':
269            return array(
270                'description' => __( 'Discover your <link>most popular videos</link> and find out how they performed.', 'jetpack-premium-analytics-pkg' ),
271                'support_url' => 'https://jetpack.com/support/jetpack-videopress/add-video-block-editor/video-stats/',
272            );
273        case 'utm':
274            return array(
275                'description' => __( 'Generate UTM parameters and track your <link>campaign performance data</link>.', 'jetpack-premium-analytics-pkg' ),
276                'support_url' => $traffic_support . '#harnessing-utm-stats-for-precision-tracking',
277            );
278        case 'devices':
279            return array(
280                'description' => __( 'See which <link>devices, browsers and OS</link> your visitors are using.', 'jetpack-premium-analytics-pkg' ),
281                'support_url' => $traffic_support . '#devices-stats',
282            );
283        case 'tags':
284            return array(
285                'description' => __( 'Track the most viewed <link>tags & categories</link>.', 'jetpack-premium-analytics-pkg' ),
286                'support_url' => 'https://wordpress.com/support/stats/learn-insights-about-your-website/#all-time-insights',
287            );
288        default:
289            return array(
290                'description' => __( 'Upgrade your plan to unlock Jetpack Stats.', 'jetpack-premium-analytics-pkg' ),
291                'support_url' => '',
292            );
293    }
294}
295
296/**
297 * Locales with translated WordPress.com support articles: Calypso's `supportSiteLocales`, which classic Stats links with.
298 */
299const WPCOM_SUPPORT_LOCALES = array( 'ar', 'de', 'es', 'fr', 'he', 'id', 'it', 'ja', 'ko', 'nl', 'pt-br', 'ru', 'sv', 'tr', 'zh-cn', 'zh-tw' );
300
301/**
302 * A WordPress.com support URL in the current user's language, where that support site has one.
303 *
304 * Leaves jetpack.com URLs in English: its localized support docs are missing for most locales.
305 *
306 * @since $$next-version$$
307 *
308 * @param string $url Support URL, in English.
309 * @return string
310 */
311function localize_stats_support_url( $url ) {
312    $prefix = 'https://wordpress.com/support/';
313    if ( ! str_starts_with( $url, $prefix ) ) {
314        return $url;
315    }
316
317    $locale = strtolower( str_replace( '_', '-', get_user_locale() ) );
318    if ( ! in_array( $locale, WPCOM_SUPPORT_LOCALES, true ) ) {
319        $locale = strtok( $locale, '-' );
320    }
321    if ( ! in_array( $locale, WPCOM_SUPPORT_LOCALES, true ) ) {
322        return $url;
323    }
324
325    return 'https://wordpress.com/' . $locale . '/support/' . substr( $url, strlen( $prefix ) );
326}
327
328/**
329 * Where to buy a locked feature: classic Stats' purchase destinations.
330 *
331 * @since $$next-version$$
332 *
333 * @param string $feature Feature key.
334 * @param string $tier    One of the STATS_TIER_* values.
335 * @return string The URL, or an empty string when there is nowhere to buy it.
336 */
337function get_stats_upgrade_url( $feature, $tier ) {
338    $return_path = 'admin.php?page=' . Analytics::MENU_PAGE_SLUG;
339
340    if ( STATS_TIER_JETPACK_FREE === $tier ) {
341        // The purchase flow lives in Odyssey Stats; with it off, `page=stats` shows only an opt-in notice.
342        $stats_page = function_exists( 'menu_page_url' ) ? menu_page_url( 'stats', false ) : '';
343        if ( ! $stats_page || ! Stats_Options::get_option( 'enable_odyssey_stats' ) ) {
344            return '';
345        }
346
347        $query = http_build_query(
348            array(
349                'from'         => 'jetpack-premium-analytics',
350                'productType'  => 'commercial',
351                'redirect_uri' => $return_path,
352            ),
353            '',
354            '&',
355            PHP_QUERY_RFC3986
356        );
357
358        return $stats_page . '#!/stats/purchase/' . (int) \Jetpack_Options::get_option( 'id' ) . '?' . $query;
359    }
360
361    $plan      = in_array( $feature, STATS_COMMERCIAL_FEATURES, true ) ? 'premium' : 'personal';
362    $site_slug = ( new Status() )->get_site_suffix();
363
364    return 'https://wordpress.com/checkout/' . rawurlencode( $site_slug ) . '/' . $plan . '?redirect_to=' . rawurlencode( admin_url( $return_path ) );
365}
366
367/**
368 * The gates the client applies: each locked feature's copy and upgrade URL, and the feature of each widget type it locks.
369 *
370 * @since $$next-version$$
371 *
372 * @return array{tier:string, locked_features:array<string,array{description:string, support_url:string, upgrade_url:string}>, locked_widget_types:array<string,string>}
373 */
374function get_stats_plan_gates() {
375    $tier     = get_stats_plan_tier();
376    $features = get_locked_stats_features( $tier );
377
378    $locked_features = array();
379    foreach ( $features as $feature ) {
380        $copy                        = get_stats_feature_copy( $feature );
381        $copy['support_url']         = localize_stats_support_url( $copy['support_url'] );
382        $locked_features[ $feature ] = $copy + array( 'upgrade_url' => get_stats_upgrade_url( $feature, $tier ) );
383    }
384
385    $locked_widget_types = array();
386    if ( $locked_features ) {
387        ensure_widget_registry_ready();
388
389        foreach ( get_available_widget_types() as $name => $widget_type ) {
390            if ( isset( $widget_type->plan_feature ) && isset( $locked_features[ $widget_type->plan_feature ] ) ) {
391                $locked_widget_types[ $name ] = $widget_type->plan_feature;
392            }
393        }
394    }
395
396    return array(
397        'tier'                => $tier,
398        'locked_features'     => $locked_features,
399        'locked_widget_types' => $locked_widget_types,
400    );
401}
402
403/**
404 * Configures the plan gates script data.
405 *
406 * @since $$next-version$$
407 *
408 * @return void
409 */
410function configure_stats_plan_gates() {
411    add_filter( 'jetpack_admin_js_script_data', __NAMESPACE__ . '\\inject_stats_plan_gates_script_data', 20 );
412    add_action( 'admin_init', __NAMESPACE__ . '\\maybe_refresh_jetpack_plan' );
413    add_action( 'admin_init', __NAMESPACE__ . '\\maybe_invalidate_stats_caches' );
414}
415
416/**
417 * Injects the plan gates into JetpackScriptData.
418 *
419 * @since $$next-version$$
420 *
421 * @param array $data The script data passed by the assets package.
422 * @return array
423 */
424function inject_stats_plan_gates_script_data( array $data ): array {
425    // Script data prints on every Jetpack admin page; only the dashboard reads the gates.
426    if ( ! Analytics::is_dashboard_request() ) {
427        return $data;
428    }
429
430    if ( ! isset( $data['premium_analytics'] ) || ! is_array( $data['premium_analytics'] ) ) {
431        $data['premium_analytics'] = array();
432    }
433
434    $gates = get_stats_plan_gates();
435
436    // Objects, so an empty map reaches JS as `{}` rather than `[]`.
437    $data['premium_analytics']['plan_gates'] = array(
438        'tier'                => $gates['tier'],
439        'locked_features'     => (object) $gates['locked_features'],
440        'locked_widget_types' => (object) $gates['locked_widget_types'],
441    );
442
443    return $data;
444}
445
446/**
447 * Transient throttling the refresh of an empty plan cache.
448 */
449const EMPTY_PLAN_REFRESH_TRANSIENT = 'jetpack_premium_analytics_empty_plan_refresh';
450
451/**
452 * Transient throttling the refresh after a purchase.
453 */
454const PURCHASE_PLAN_REFRESH_TRANSIENT = 'jetpack_premium_analytics_purchase_plan_refresh';
455
456/**
457 * Transient throttling the cache invalidation after a purchase.
458 */
459const PURCHASE_CACHE_INVALIDATION_TRANSIENT = 'jetpack_premium_analytics_purchase_cache_invalidation';
460
461/**
462 * Drops the cached Stats replies a checkout made stale, before the dashboard it returns to asks for them.
463 *
464 * @since $$next-version$$
465 *
466 * @return void
467 */
468function maybe_invalidate_stats_caches() {
469    // Simple serves the dashboard from WordPress.com, with neither cache.
470    if ( ! Analytics::is_dashboard_request() || '' === get_stats_purchase_success_type() || ( new Host() )->is_wpcom_simple() ) {
471        return;
472    }
473
474    // The marker is a plain GET parameter, so a crafted link could otherwise drop the caches on every visit.
475    if ( get_transient( PURCHASE_CACHE_INVALIDATION_TRANSIENT ) ) {
476        return;
477    }
478    set_transient( PURCHASE_CACHE_INVALIDATION_TRANSIENT, 1, MINUTE_IN_SECONDS );
479
480    Api_Proxy_Controller::invalidate_cache();
481    delete_transient( Notices::NOTICES_CACHE_KEY );
482}
483
484/**
485 * Refreshes a self-hosted site's cached plan on the dashboard when it is empty or a checkout just returned.
486 *
487 * Script data is final once printed, so the gates can only change before it.
488 *
489 * @since $$next-version$$
490 *
491 * @return void
492 */
493function maybe_refresh_jetpack_plan() {
494    if ( ! Analytics::is_dashboard_request() || ( new Host() )->is_wpcom_platform() ) {
495        return;
496    }
497
498    if ( '' !== get_stats_purchase_success_type() ) {
499        $transient = PURCHASE_PLAN_REFRESH_TRANSIENT;
500        $ttl       = MINUTE_IN_SECONDS;
501    } elseif ( ! has_cached_jetpack_plan() ) {
502        $transient = EMPTY_PLAN_REFRESH_TRANSIENT;
503        $ttl       = 15 * MINUTE_IN_SECONDS;
504    } else {
505        return;
506    }
507
508    if ( get_transient( $transient ) ) {
509        return;
510    }
511    set_transient( $transient, 1, $ttl );
512
513    Current_Plan::refresh_from_wpcom( array( 'timeout' => 5 ) );
514}