Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.50% covered (success)
92.50%
37 / 40
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Podcast_Gate
92.50% covered (success)
92.50%
37 / 40
40.00% covered (danger)
40.00%
2 / 5
25.26
0.00% covered (danger)
0.00%
0 / 1
 has_product_access
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 get_required_plan_slug
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 self_hosted_has_paid_plan
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 get_site_current_purchases
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 is_grandfathered
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
1<?php
2/**
3 * Podcast product-access gate.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8namespace Automattic\Jetpack\Podcast;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Current_Plan;
12use Automattic\Jetpack\Status\Host;
13use Jetpack_Options;
14
15/**
16 * Premium podcast feature gate (dashboard, stats, episode block). Two paths:
17 *
18 * - WordPress.com (Simple/WoA): the `podcasting` plan feature via
19 *   `Current_Plan::supports`, plus the launch-day grandfather rule. Request-
20 *   scoped, so gating another blog needs `switch_to_blog` first.
21 * - Self-hosted Jetpack: a Growth/Complete purchase over the connection
22 *   (PODS-123). Only consulted in admin/editor contexts.
23 */
24class Podcast_Gate {
25
26    const FEATURE_SLUG = 'podcasting';
27
28    /**
29     * Launch-day cutoff for the paying-blog grandfather rule. Paid blogs
30     * registered before this date keep Premium podcast features without
31     * needing the `podcasting` plan feature. WordPress.com only.
32     */
33    const GRANDFATHER_CUTOFF_DATE = '2026-05-18';
34
35    /**
36     * Transient holding the cached `/upgrades` response. Short-lived (30s): dedupes
37     * the lookup across the successive editor/admin loads that consult the gate,
38     * without a synchronous WPCOM request on each one.
39     */
40    const PURCHASES_TRANSIENT = 'jetpack_podcast_site_purchases';
41
42    /**
43     * Whether the current site can use the paid podcast surfaces.
44     *
45     * @return bool
46     */
47    public static function has_product_access(): bool {
48        if ( ! ( new Host() )->is_wpcom_platform() ) {
49            return self::self_hosted_has_paid_plan();
50        }
51
52        $blog_id = get_current_blog_id();
53        if ( $blog_id <= 0 ) {
54            return false;
55        }
56
57        if ( self::is_grandfathered( $blog_id ) ) {
58            return true;
59        }
60
61        return (bool) Current_Plan::supports( self::FEATURE_SLUG );
62    }
63
64    /**
65     * The minimum plan slug to upsell: WordPress.com Premium (`value_bundle`) or
66     * Jetpack Growth (`jetpack_growth_yearly`).
67     *
68     * @return string
69     */
70    public static function get_required_plan_slug(): string {
71        return ( new Host() )->is_wpcom_platform() ? 'value_bundle' : 'jetpack_growth_yearly';
72    }
73
74    /**
75     * Whether a self-hosted site owns a Growth/Complete plan. Matches purchased
76     * product slugs, not the `podcasting` feature (which is true for every Jetpack
77     * site on WordPress.com and can't tell free from paid here).
78     */
79    private static function self_hosted_has_paid_plan(): bool {
80        foreach ( self::get_site_current_purchases() as $purchase ) {
81            $slug = is_array( $purchase ) && isset( $purchase['product_slug'] ) ? $purchase['product_slug'] : '';
82
83            // Prefix match so every Growth/Complete billing term counts.
84            foreach ( array( 'jetpack_growth', 'jetpack_complete' ) as $prefix ) {
85                if ( is_string( $slug ) && 0 === strpos( $slug, $prefix ) ) {
86                    return true;
87                }
88            }
89        }
90
91        return false;
92    }
93
94    /**
95     * The site's current purchases from WordPress.com (`/upgrades`). Cached in a
96     * short transient so successive gate checks don't each fire a WPCOM request.
97     * Fails closed to an empty list on any error, without caching it, so the next
98     * request retries rather than serving a stale empty.
99     *
100     * @return array Purchase entries; empty on failure.
101     */
102    private static function get_site_current_purchases(): array {
103        $cached = get_transient( self::PURCHASES_TRANSIENT );
104        if ( is_array( $cached ) ) {
105            return $cached;
106        }
107
108        $response = Client::wpcom_json_api_request_as_blog(
109            sprintf( '/upgrades?site=%d', (int) Jetpack_Options::get_option( 'id' ) ),
110            '1.2',
111            array( 'method' => 'GET' )
112        );
113
114        if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
115            return array();
116        }
117
118        $decoded = json_decode( wp_remote_retrieve_body( $response ), true );
119        if ( ! is_array( $decoded ) ) {
120            return array();
121        }
122
123        set_transient( self::PURCHASES_TRANSIENT, $decoded, 30 );
124        return $decoded;
125    }
126
127    /**
128     * Whether the blog is grandfathered: registered before the cutoff AND on a paid plan.
129     *
130     * @param int $blog_id Blog ID.
131     */
132    protected static function is_grandfathered( int $blog_id ): bool {
133        if ( ! function_exists( 'get_blog_details' ) ) {
134            return false;
135        }
136        $details = get_blog_details( $blog_id );
137        if ( ! $details || empty( $details->registered ) ) {
138            return false;
139        }
140        $registered_ts = strtotime( $details->registered );
141        if ( false === $registered_ts || $registered_ts >= strtotime( self::GRANDFATHER_CUTOFF_DATE ) ) {
142            return false;
143        }
144
145        $plan = Current_Plan::get();
146        return ! empty( $plan['class'] ) && 'free' !== $plan['class'];
147    }
148}