Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.62% covered (success)
93.62%
44 / 47
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Podcast_Gate
93.62% covered (success)
93.62%
44 / 47
40.00% covered (danger)
40.00%
2 / 5
25.16
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
 flush_purchases_cache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 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
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 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.
17 *
18 * Resolves the paid surfaces (episode dashboard, stats, episode block) two
19 * ways depending on the host:
20 *
21 * - WordPress.com (Simple/WoA): the `podcasting` plan feature via
22 *   `Current_Plan::supports`, plus the launch-day grandfather rule. Reads
23 *   request-scoped state, so callers gating a different blog must
24 *   `switch_to_blog` first.
25 * - Self-hosted Jetpack: the site's purchased plan over the Jetpack
26 *   connection. Per PODS-123, the Growth (and Complete) plans unlock the paid
27 *   surfaces; everything else is feed-only.
28 */
29class Podcast_Gate {
30
31    const FEATURE_SLUG = 'podcasting';
32
33    /**
34     * Launch-day cutoff for the paying-blog grandfather rule. Paid blogs
35     * registered before this date keep Premium podcast features without
36     * needing the `podcasting` plan feature. WordPress.com only.
37     */
38    const GRANDFATHER_CUTOFF_DATE = '2026-05-18';
39
40    /**
41     * Transient holding the cached `/upgrades` response. Short-lived (30s, set
42     * below): mainly dedupes the lookup across a single page load. A buyer
43     * returning from checkout busts it outright via `flush_purchases_cache()`,
44     * so the TTL only bounds the unlikely case where that signal is missed.
45     */
46    const PURCHASES_TRANSIENT = 'jetpack_podcast_site_purchases';
47
48    /**
49     * Request-scoped memo of the purchases lookup (including failures, so a
50     * failed fetch isn't retried mid-request). Null until first resolved.
51     *
52     * @var array|null
53     */
54    private static $purchases_cache = null;
55
56    /**
57     * Whether the current site can use the paid podcast surfaces.
58     *
59     * @return bool
60     */
61    public static function has_product_access(): bool {
62        if ( ! ( new Host() )->is_wpcom_platform() ) {
63            return self::self_hosted_has_paid_plan();
64        }
65
66        $blog_id = get_current_blog_id();
67        if ( $blog_id <= 0 ) {
68            return false;
69        }
70
71        if ( self::is_grandfathered( $blog_id ) ) {
72            return true;
73        }
74
75        return (bool) Current_Plan::supports( self::FEATURE_SLUG );
76    }
77
78    /**
79     * Drop the cached purchases lookup so the next access check re-reads
80     * `/upgrades`. Called when a buyer returns from checkout so a fresh plan
81     * unlocks the paid surfaces immediately rather than after the TTL.
82     */
83    public static function flush_purchases_cache(): void {
84        delete_transient( self::PURCHASES_TRANSIENT );
85        self::$purchases_cache = null;
86    }
87
88    /**
89     * Whether a self-hosted Jetpack site owns a Growth (or Complete) plan.
90     *
91     * Mirrors the bundle-detection pattern used by My Jetpack's Growth/Security
92     * products: match purchased product slugs rather than the `podcasting`
93     * feature, which maps to all Jetpack sites on WordPress.com and so can't
94     * distinguish free from paid here.
95     */
96    private static function self_hosted_has_paid_plan(): bool {
97        foreach ( self::get_site_current_purchases() as $purchase ) {
98            $slug = is_array( $purchase ) && isset( $purchase['product_slug'] ) ? $purchase['product_slug'] : '';
99
100            // Growth and Complete bundles unlock the paid surfaces; matched as
101            // prefixes so every billing term/tier counts.
102            foreach ( array( 'jetpack_growth', 'jetpack_complete' ) as $prefix ) {
103                if ( is_string( $slug ) && 0 === strpos( $slug, $prefix ) ) {
104                    return true;
105                }
106            }
107        }
108
109        return false;
110    }
111
112    /**
113     * The site's current purchases from WordPress.com (`/upgrades`).
114     *
115     * Fails closed: an unreachable or malformed response returns no purchases
116     * and isn't written to the transient, so the next request retries rather
117     * than serving a stale empty result.
118     *
119     * @return array List of purchase entries (associative arrays); empty on failure.
120     */
121    private static function get_site_current_purchases(): array {
122        if ( null !== self::$purchases_cache ) {
123            return self::$purchases_cache;
124        }
125
126        $cached = get_transient( self::PURCHASES_TRANSIENT );
127        if ( is_array( $cached ) ) {
128            self::$purchases_cache = $cached;
129            return self::$purchases_cache;
130        }
131
132        $response = Client::wpcom_json_api_request_as_blog(
133            sprintf( '/upgrades?site=%d', (int) Jetpack_Options::get_option( 'id' ) ),
134            '1.2',
135            array( 'method' => 'GET' )
136        );
137
138        if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
139            self::$purchases_cache = array();
140            return self::$purchases_cache;
141        }
142
143        $decoded = json_decode( wp_remote_retrieve_body( $response ), true );
144        if ( ! is_array( $decoded ) ) {
145            self::$purchases_cache = array();
146            return self::$purchases_cache;
147        }
148
149        // 30s: short enough that a plan change shows up quickly even if the
150        // checkout-return bust is missed, long enough to dedupe a page load.
151        set_transient( self::PURCHASES_TRANSIENT, $decoded, 30 );
152        self::$purchases_cache = $decoded;
153        return self::$purchases_cache;
154    }
155
156    /**
157     * Whether the blog is grandfathered: registered before the cutoff AND on a paid plan.
158     *
159     * @param int $blog_id Blog ID.
160     */
161    protected static function is_grandfathered( int $blog_id ): bool {
162        if ( ! function_exists( 'get_blog_details' ) ) {
163            return false;
164        }
165        $details = get_blog_details( $blog_id );
166        if ( ! $details || empty( $details->registered ) ) {
167            return false;
168        }
169        $registered_ts = strtotime( $details->registered );
170        if ( false === $registered_ts || $registered_ts >= strtotime( self::GRANDFATHER_CUTOFF_DATE ) ) {
171            return false;
172        }
173
174        $plan = Current_Plan::get();
175        return ! empty( $plan['class'] ) && 'free' !== $plan['class'];
176    }
177}