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