Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.24% covered (danger)
21.24%
41 / 193
0.00% covered (danger)
0.00%
0 / 11
CRAP
n/a
0 / 0
_wpcom_get_current_blog_id
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
wpcom_site_has_feature
65.22% covered (warning)
65.22%
15 / 23
0.00% covered (danger)
0.00%
0 / 1
42.37
wpcom_site_can_upload_videos
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
8.30
wpcom_get_site_purchases
61.90% covered (warning)
61.90%
13 / 21
0.00% covered (danger)
0.00%
0 / 1
7.99
_wpcom_features_get_simple_site_purchases
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
240
wpcom_datetime_to_iso8601
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
wpcom_product_has_feature
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
wpcom_purchase_has_feature
20.00% covered (danger)
20.00%
2 / 10
0.00% covered (danger)
0.00%
0 / 1
4.05
wpcom_get_product_features
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
110
_convert_product_to_purchase
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
wpcom_feature_exists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * THIS FILE EXISTS VERBATIM IN WPCOM AND WPCOMSH.
4 *
5 * DANGER DANGER DANGER!!!
6 * If you make any changes to this file you must MANUALLY update this file in both WPCOM and WPCOMSH.
7 *
8 * This file provides WPCOM_Features class wrapper functions that make checking for a specific feature easy and uniform
9 * across WPCOM and WPCOMSH.
10 *
11 * @package WPCOM_Features
12 */
13
14/**
15 * Load `WPCOM_Features` class.
16 */
17require_once __DIR__ . '/class-wpcom-features.php';
18require_once __DIR__ . '/class-wpcom-site-purchase.php';
19
20/**
21 * Internal function to retrieve the current WP.com blog ID depending on the environment.
22 *
23 * @return int The current blog ID.
24 */
25function _wpcom_get_current_blog_id() {
26    if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
27        return get_current_blog_id();
28    }
29
30    /*
31     * Atomic sites have the WP.com blog ID stored as a Jetpack option. This code deliberately
32     * doesn't use `Jetpack_Options::get_option` so it works even when Jetpack has not been loaded.
33     */
34    $jetpack_options = get_option( 'jetpack_options' );
35    if ( is_array( $jetpack_options ) && isset( $jetpack_options['id'] ) ) {
36        return (int) $jetpack_options['id'];
37    }
38
39    return get_current_blog_id();
40}
41
42/**
43 * Whether a given feature is available to the current (or specified) site.
44 *
45 * This function pulls the purchases for a given site and uses WPCOM_Features to check if any of those purchases
46 * include the requested $feature.
47 *
48 * @param string $feature A singular feature.
49 * @param int    $blog_id Optional. Blog ID. Defaults to current blog.
50 *
51 * @return bool Does the site have the feature?
52 */
53function wpcom_site_has_feature( $feature, $blog_id = 0 ) {
54    if ( ! $blog_id ) {
55        $blog_id = _wpcom_get_current_blog_id();
56    }
57
58    $blog = null;
59    if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
60        $site_type = 'wpcom';
61    } else {
62        $blog      = get_blog_details( $blog_id, false );
63        $site_type = is_blog_wpcom( $blog ) || is_blog_atomic( $blog ) ? 'wpcom' : 'jetpack';
64    }
65
66    // A8C override for certain sites.
67    if ( $feature === WPCOM_Features::ADVANCED_SEO && in_array( $blog_id, WPCOM_FEATURES::A8C_SITES_WITH_ADDITIONAL_SEO_FEATURES, true ) ) {
68        return true;
69    }
70
71    /*
72     * A8C override for internal P2s
73     */
74    if ( $feature === WPCOM_Features::AI_ASSISTANT && ( function_exists( 'wpcom_is_automattic_p2_site' ) && wpcom_is_automattic_p2_site( $blog_id ) ) ) {
75        return true;
76    }
77
78    /*
79     * A8C override for wp.org sites to enable JP search
80     */
81    if ( $feature === WPCOM_Features::CLASSIC_SEARCH && ( function_exists( 'wpcom_is_wporg_jp_index' ) && wpcom_is_wporg_jp_index( $blog_id ) ) ) {
82        return true;
83    }
84
85    /*
86     * Sites carrying the 'wordads-free-access' sticker have WordAds regardless of plan.
87     * The wpcom features list honours that sticker separately, but Jetpack gates module
88     * activation through its plan check, which defers here, so without this the sticker
89     * has no effect on Atomic.
90     */
91    if ( in_array( $feature, array( WPCOM_Features::WORDADS, WPCOM_Features::WORDADS_JETPACK ), true ) ) {
92        if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC && function_exists( 'wpcomsh_is_site_sticker_active' ) ) {
93            if ( wpcomsh_is_site_sticker_active( 'wordads-free-access' ) ) {
94                return true;
95            }
96        } elseif ( function_exists( 'has_blog_sticker' ) && has_blog_sticker( 'wordads-free-access', $blog_id ) ) {
97            return true;
98        }
99    }
100
101    $purchases = wpcom_get_site_purchases( $blog_id );
102
103    if ( isset( $blog->registered ) ) {
104        WPCOM_Features::add_free_plan_purchase( $purchases, $site_type, $blog->registered );
105    }
106
107    return WPCOM_Features::has_feature( $feature, $purchases, $site_type, $blog_id );
108}
109
110/**
111 * Find out if the site can upload video files.
112 *
113 * This checks if the site has either VideoPress or the general video upload capability.
114 * Sites with the UPLOAD_VIDEO_FILES feature can upload videos even without VideoPress
115 * (e.g. Premium plans with the gating-business-q1 sticker).
116 *
117 * @param int $blog_id Blog ID. Defaults to the current blog ID if none is passed.
118 * @return bool Whether the site can upload video files.
119 */
120function wpcom_site_can_upload_videos( $blog_id = 0 ) {
121    if ( ! $blog_id ) {
122        $blog_id = _wpcom_get_current_blog_id();
123    }
124
125    // VideoPress includes video upload capability.
126    // On WPCOM, use wpcom_site_has_videopress() to respect the filter.
127    // On WPCOMSH/Atomic, that function doesn't exist so use direct feature check.
128    if ( function_exists( 'wpcom_site_has_videopress' ) ) {
129        if ( wpcom_site_has_videopress( $blog_id ) ) {
130            return true;
131        }
132    } elseif ( wpcom_site_has_feature( WPCOM_Features::VIDEOPRESS, $blog_id ) ) {
133        return true;
134    }
135
136    // Check for the general video upload feature (Premium+ plans with gating-business-q1 sticker).
137    if ( wpcom_site_has_feature( WPCOM_Features::UPLOAD_VIDEO_FILES, $blog_id ) ) {
138        return true;
139    }
140
141    /**
142     * Filters whether the site can upload video files.
143     *
144     * @param bool $can_upload_videos Whether the site can upload video files.
145     * @param int  $blog_id Blog ID.
146     */
147    return apply_filters( 'wpcom_site_can_upload_videos', false, $blog_id );
148}
149
150/**
151 * Returns a list of purchased products.
152 *
153 * This function checks if we're on an Atomic (WPCOMSH) or Simple (WPCOM) site, and pulls the purchases for that current
154 * site.
155 *
156 * @throws Error If $blog_id !== current_blog_id on Atomic sites.
157 *
158 * @param int $blog_id Optional. Blog ID. Defaults to current blog.
159 *
160 * @return array An array of WPCOM_Site_Purchase objects.
161 */
162function wpcom_get_site_purchases( $blog_id = 0 ) {
163    if ( ! $blog_id ) {
164        $blog_id = _wpcom_get_current_blog_id();
165    }
166
167    if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
168        if ( _wpcom_get_current_blog_id() !== $blog_id ) {
169            throw new Error(
170                'Atomic sites do not support looking up features for sites other than the current site.'
171            );
172        }
173
174        // Atomic site (WPCOMSH) purchases are stored in Atomic Persistent Data as a JSON encoded string.
175        $persistent_data = new Atomic_Persistent_Data();
176
177        if ( ! $persistent_data->WPCOM_PURCHASES ) { // phpcs:ignore WordPress.NamingConventions
178            return array();
179        }
180
181        // An entry that is not an object cannot be a purchase. Skipping keeps a payload of the
182        // wrong shape a degraded answer rather than a fatal on a near-universal code path.
183        $entries = array_filter( (array) json_decode( $persistent_data->WPCOM_PURCHASES ), 'is_object' ); // phpcs:ignore WordPress.NamingConventions
184
185        $purchases = array_map(
186            static fn ( $entry ) => WPCOM_Site_Purchase::from_synced_payload( $entry, $blog_id ),
187            array_values( $entries )
188        );
189
190    } else {
191        // Allow overriding the blog ID for feature checks.
192        $blog_id = apply_filters( 'wpcom_site_has_feature_blog_id', $blog_id );
193
194        $purchases = array_map(
195            static fn ( $row ) => WPCOM_Site_Purchase::from_store_row( $row, $blog_id ),
196            _wpcom_features_get_simple_site_purchases( $blog_id )
197        );
198    }
199
200    return $purchases;
201}
202
203/**
204 * INTERNAL function to fetch purchases for a WPCOM Simple site.
205 * The function will return an empty array if we're running in an Atomic context.
206 *
207 * @param int $blog_id The blog ID to fetch purchases for.
208 * @return array The currently active purchases on the site.
209 */
210function _wpcom_features_get_simple_site_purchases( $blog_id ) {
211    global $wpdb;
212
213    if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
214        // Make _super_ sure the function is available.
215        if ( function_exists( '_doing_it_wrong' ) ) {
216            _doing_it_wrong(
217                __FUNCTION__,
218                'Support for this function is only in available in contexts where the WordPress.com Store databases are available.',
219                false // No version.
220            );
221        }
222
223        return array();
224    }
225
226    if ( ! $blog_id ) {
227        $blog_id = _wpcom_get_current_blog_id();
228    }
229
230    // Optional skip, used for suspended/spam/deleted sites.
231    $skip_purchase_lookup = apply_filters( 'wpcom_simple_skip_purchase_lookup', false, $blog_id );
232    if ( $skip_purchase_lookup ) {
233        return array();
234    }
235
236    // 'site_purchases' belong to $global_groups in ./wp-content/object-cache.php
237    $wp_cache_group = 'site_purchases';
238    $wp_cache_found = false;
239
240    // The DB table is included in $wp_cache_key to avoid cache pollution between the production and test store.
241    $wp_cache_key = "$blog_id-{$wpdb->store_subscriptions}";
242
243    // Check wp_cache_get() for $purchases. If none exist $wp_cache_found will be false.
244    $purchases = wp_cache_get( $wp_cache_key, $wp_cache_group, false, $wp_cache_found );
245
246    if ( false !== $wp_cache_found ) {
247        return (array) $purchases;
248    }
249
250    $lock_key   = "{$wp_cache_key}_building";
251    $lock_found = false;
252    // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
253    $lock = wp_cache_get( $lock_key, $wp_cache_group, false, $lock_found );
254    if ( $lock_found ) {
255        // Another request is already rebuilding the purchases cache.
256        // Let's try 2 short sleeps of 200ms to see if that finishes, and if not,
257        // return an empty set of purchases. The DB must be backed up and we don't
258        // want to contribute to the problem.
259        $attempts = 2;
260        while ( $attempts > 0 ) {
261            // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
262            $lock = wp_cache_get( $lock_key, $wp_cache_group, true /* force remote recheck */, $lock_found );
263            if ( ! $lock_found ) {
264                // The lock is no longer in cache, so we can break out of the loop.
265                break;
266            }
267            // Sleep 200ms
268            usleep( 200000 );
269            --$attempts;
270        }
271
272        // Has the first request has populated the cache?
273        $purchases = wp_cache_get( $wp_cache_key, $wp_cache_group, true /* force remote recheck */, $wp_cache_found );
274        if ( false !== $wp_cache_found ) {
275            return (array) $purchases;
276        }
277
278        // It's still not there, return [] as a fallback;
279        // Also let future checks for this blog for the life of this request fail
280        $target_blog_id = $blog_id;
281        add_filter(
282            'wpcom_simple_skip_purchase_lookup',
283            function ( $skip, $checked_blog_id ) use ( $target_blog_id ) {
284                if ( $checked_blog_id === $target_blog_id ) {
285                    return true; // Skip purchases lookup
286                }
287                return $skip;
288            },
289            10,
290            2
291        );
292        return array();
293    }
294
295    // Let other requests know that we are rebuilding purchases, to stop multiples of the same
296    // request from stacking up.
297    wp_cache_set( $lock_key, true, $wp_cache_group, 30 ); // (30 second TTL)
298
299    // Get $purchases with a direct SQL query.
300    // We are intentionally NOT using the Purchases API as this code needs to be runnable
301    // in some contexts where the billing code-base is not available.
302    // See pdqkMK-18D-p2 for more discussion and context.
303
304    // phpcs:ignore WordPress.DB.DirectDatabaseQuery
305    $purchases = $wpdb->get_results(
306        $wpdb->prepare(
307            "
308                    SELECT
309                        product.product_slug,
310                        product.product_id,
311                        product.billing_product_id,
312                        product.product_type,
313                        subscription.subscribed_date,
314                        subscription.expiry AS 'expiry_date',
315                        subscription.id AS subscription_id,
316                        subscription.auto_renew AS user_allows_auto_renew
317                    FROM `$wpdb->store_subscriptions` AS subscription
318                    LEFT JOIN `$wpdb->store_products` AS product ON subscription.product_id = product.product_id
319                    WHERE
320                        subscription.blog_id = %d
321                        AND subscription.active = 1
322                    ORDER BY subscription.id DESC
323                    ",
324            $blog_id
325        )
326    );
327
328    static $billing_product_data = array();
329
330    $billing_product_ids          = array_unique( wp_list_pluck( $purchases, 'billing_product_id' ) );
331    $billing_product_ids_to_query = array_diff( $billing_product_ids, array_keys( $billing_product_data ) );
332    if ( ! empty( $billing_product_ids_to_query ) ) {
333        // We need to query the billing_products table via a separate query.
334
335        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
336        $billing_products = $wpdb->get_results(
337            "
338                    SELECT
339                        product_id,
340                        product_slug
341                    FROM
342                        `$wpdb->billing_products`
343                    WHERE
344                        " . $wpdb->build_IN_condition( 'product_id', $billing_product_ids_to_query, '%d' ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
345        );
346
347        foreach ( $billing_products as $billing_product ) {
348            $billing_product_data[ $billing_product->product_id ] = $billing_product->product_slug;
349        }
350    }
351
352    // Format the dates to match WPCOMSH data.
353    foreach ( $purchases as $purchase ) {
354        $purchase->billing_product_slug   = $billing_product_data[ $purchase->billing_product_id ] ?? '';
355        $purchase->subscribed_date        = wpcom_datetime_to_iso8601( $purchase->subscribed_date );
356        $purchase->expiry_date            = wpcom_datetime_to_iso8601( $purchase->expiry_date );
357        $purchase->user_allows_auto_renew = ! empty( $purchase->user_allows_auto_renew );
358        // Ensure we remove billing_product_id from the purchase data.
359        unset( $purchase->billing_product_id );
360    }
361
362    /*
363    * Cache the $purchases for 6-8 hours. Otherwise, the cache is invalidated when a purchase is made, using:
364    * add_action( 'subscription_changed', 'clear_wp_cache_site_purchases', 10, 1 );
365    * Found in ./wp-content/mu-plugins/wpcom-features.php
366    */
367    // phpcs:ignore WordPress.WP.AlternativeFunctions.rand_mt_rand
368    wp_cache_set( $wp_cache_key, $purchases, $wp_cache_group, ( 6 * HOUR_IN_SECONDS ) + mt_rand( 1, 2 * 60 * MINUTE_IN_SECONDS ) );
369    wp_cache_delete( $lock_key, $wp_cache_group ); // Release the lock telling other requests we are building purchases.
370
371    return $purchases;
372}
373
374/**
375 * Parse and format a date string to ISO8601, but fall back to $default if the string is bad or '0000-00-00'.
376 *
377 * @param string $date A string representing a datetime that we wish to format to ISO8601.
378 * @param string $default Use this datetime if $date errors. Useful for predictable unit testing. Defaults to 'now'.
379 *
380 * @return string A date string in ISO8601 format.
381 */
382function wpcom_datetime_to_iso8601( $date, $default = 'now' ) {
383    /*
384     * Datetimes containing '0000-00-00' convert to '-001-11-30T00:00:00+00:00' which is not useful, so set it or
385     * empty $date to $default.
386     */
387    if ( empty( $date ) || false !== strpos( $date, '0000-00-00' ) ) {
388        $date = $default;
389    }
390
391    try {
392        return ( new DateTime( $date ) )->format( 'c' );
393    } catch ( Exception $e ) {
394        return ( new DateTime( $default ) )->format( 'c' );
395    }
396}
397
398/**
399 * Checks whether the given product contains the passed feature.
400 *
401 * This function converts atomic supported plan slugs and other product aliases to product objects. It then uses
402 * WPCOM_Features to check if product include the requested $feature.
403 *
404 * Do not pass a Store_Subscription to this function. For that case, use wpcom_purchase_has_feature().
405 *
406 * @param string|int|Store_Product $product A Store_Product object, a product slug, or ID.
407 * @param string                   $feature The name of the feature to check.
408 *
409 * @return bool
410 */
411function wpcom_product_has_feature( $product, $feature ) {
412    if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
413        _doing_it_wrong(
414            __FUNCTION__,
415            'Support for this function is only in available in contexts where the store products database is available.',
416            '' // No version.
417        );
418        return false;
419    }
420
421    $purchase = _convert_product_to_purchase( $product );
422    if ( ! $purchase ) {
423        return false;
424    }
425
426    return wpcom_purchase_has_feature( $purchase, $feature );
427}
428
429/**
430 * Checks whether the given purchase (Store_Subscription) contains the passed feature.
431 *
432 * This function is similar to `wpcom_product_has_feature` with the difference that this function can check for legacy
433 * features because purchases contain a `subscribed_date` field whereas products do not.
434 *
435 * Do not pass a Store_Product to this function. For that case, use wpcom_product_has_feature().
436 *
437 * @param Store_Subscription|object $purchase A Store_Subscription object or purchase serialized object.
438 * @param string                    $feature The name of the feature to check.
439 *
440 * @return bool
441 */
442function wpcom_purchase_has_feature( $purchase, $feature ) {
443    if ( $purchase instanceof Store_Subscription ) {
444        /**
445         * We retrieve the product_slug and product_type directly from the Store_Product_List
446         * cache instead of relying on the internals of Store_Subscription to retrieve it.
447         *
448         * The issue is that simply "->product_slug" or "->product_type" can call a custom __get(),
449         * which can issue SQL queries that take > 10ms for information is not needed. This assignment
450         * grabs the value directly from the cached store_products data avoiding any unnecessary queries.
451         */
452        $product = Store_Product_List::get_from_cache()[ $purchase->product_id ];
453
454        $purchase = (object) array(
455            'product_slug'    => $product['product_slug'],
456            'product_id'      => (string) $purchase->product_id,
457            'product_type'    => $product['product_type'],
458            'subscribed_date' => wpcom_datetime_to_iso8601( $purchase->subscribed_date ),
459            'expiry_date'     => wpcom_datetime_to_iso8601( $purchase->expiry ),
460        );
461    }
462
463    return WPCOM_Features::has_feature( $feature, array( $purchase ) );
464}
465
466/**
467 * Returns a list of features that are associated with the passed product.
468 *
469 * @param string|int|Store_Product $product A Store_Product object, a product slug, or ID.
470 *
471 * @return string[]
472 */
473function wpcom_get_product_features( $product ) {
474    if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
475        _doing_it_wrong(
476            __FUNCTION__,
477            'Support for this function is only in available in contexts where the store products database is available.',
478            '' // No version.
479        );
480        return array();
481    }
482    // @codeCoverageIgnoreStart
483    $purchase = _convert_product_to_purchase( $product );
484    if ( ! $purchase ) {
485        return array();
486    }
487
488    $cache_group = 'site_purchases';
489    $cache_found = false;
490
491    /*
492    Adjust the cache key to include site legacy/gating_2026 status. Applying it to all plans
493     * and not just Personal/Premium to make it future-proof and avoid issue like
494     * WPCOM_Store::get_wpcom_personal_plans() not returning the Personal trial plan.
495     *
496     * The cohort is not the only per-site input: `required_sticker` branches read stickers too, and
497     * `site_purchases` is a global cache group. WPCOM_Features builds the whole suffix because it
498     * owns the map that decides what varies.
499     *
500     * method_exists() covers the deploy window, when this file can land before the class does. The
501     * class that lacks the method still carries the pre-2026 map, which varies only on
502     * `gating-business-q1`, so the fallback keys on that rather than dropping the cohort from the
503     * key entirely -- an empty suffix would let two Personal/Premium sites in different cohorts
504     * share one entry. Drop the guard once the class change is fully deployed.
505     */
506    $blog_id_for_gating = _wpcom_get_current_blog_id();
507    if ( method_exists( 'WPCOM_Features', 'get_site_gating_cache_suffix' ) ) {
508        $gating_cache_suffix = WPCOM_Features::get_site_gating_cache_suffix( $blog_id_for_gating );
509    } elseif ( function_exists( 'has_blog_sticker' ) && has_blog_sticker( 'gating-business-q1', $blog_id_for_gating ) ) {
510        $gating_cache_suffix = '_gatingbq1';
511    } else {
512        $gating_cache_suffix = '';
513    }
514
515    $cache_key = $purchase->product_slug
516        . $gating_cache_suffix
517        . filemtime( __DIR__ . '/class-wpcom-features.php' );
518    $features  = wp_cache_get( $cache_key, $cache_group, false, $cache_found );
519
520    if ( false === $cache_found ) {
521        $features = array();
522
523        foreach ( WPCOM_Features::get_feature_slugs() as $feature ) {
524            if ( wpcom_purchase_has_feature( $purchase, $feature ) ) {
525                $features[] = $feature;
526            }
527        }
528
529        wp_cache_set( $cache_key, $features, $cache_group, DAY_IN_SECONDS );
530    }
531
532    return $features;
533    // @codeCoverageIgnoreEnd
534}
535
536/**
537 * Converts a store product to a purchase object compatible with `WPCOM_Features::has_feature`.
538 *
539 * @param string|int|Store_Product $product A Store_Product object, a product slug, or ID.
540 *
541 * @return null|object
542 */
543function _convert_product_to_purchase( $product ) {
544    if ( ! is_numeric( $product ) && ! is_string( $product ) && ! ( $product instanceof Store_Product ) ) {
545        _doing_it_wrong(
546            __FUNCTION__,
547            'The $purchase parameter should be of type string|int|Store_Product.',
548            false // No version.
549        );
550        return null;
551    }
552
553    if ( is_string( $product ) && ! is_numeric( $product ) ) {
554        require_once WP_CONTENT_DIR . '/admin-plugins/wpcom-billing/class.wpcom-billingdaddy.php';
555        $product = WPCOM_Billingdaddy::store_product_slug_to_product_id( $product );
556    }
557
558    if ( is_numeric( $product ) ) {
559        $product_cache = Store_Product_List::get_from_cache();
560        if ( ! array_key_exists( $product, $product_cache ) ) {
561            return null;
562        }
563        $product = (object) $product_cache[ $product ];
564    }
565
566    return (object) array(
567        'product_slug'    => $product->product_slug,
568        'product_id'      => (string) $product->product_id,
569        'product_type'    => $product->product_type,
570        'subscribed_date' => null,
571        'expiry_date'     => null,
572    );
573}
574
575/**
576 * Checks whether the given feature exists in WordPress.com.
577 *
578 * @param string $feature The name of the feature to check.
579 *
580 * @return bool Whether the feature exists.
581 */
582function wpcom_feature_exists( $feature ) {
583    return WPCOM_Features::feature_exists( $feature );
584}