Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.96% covered (warning)
85.96%
49 / 57
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Expiry_Owner
85.71% covered (warning)
85.71%
48 / 56
85.71% covered (warning)
85.71%
6 / 7
44.66
0.00% covered (danger)
0.00%
0 / 1
 current_user_is_owner
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 current_user_wpcom_id
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 owner_id
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 cache_key
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 pick_owner_id
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 upgrade_matches
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
 simple_site_upgrades
33.33% covered (danger)
33.33%
4 / 12
0.00% covered (danger)
0.00%
0 / 1
26.96
1<?php
2/**
3 * Expiry_Owner: whether the viewer is the account that bought the plan.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8declare( strict_types = 1 );
9
10namespace Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices;
11
12use Automattic\Jetpack\Connection\Manager as Connection_Manager;
13use Automattic\Jetpack\Constants;
14
15require_once __DIR__ . '/class-expiry-wpcom.php';
16
17/**
18 * Only the WordPress.com account that bought a subscription can renew it; the
19 * store refuses anyone else at checkout, so each surface asks this before
20 * offering a renewal.
21 */
22class Expiry_Owner {
23
24    const CACHE_KEY_PREFIX = 'wpcom_expiry_notices_owner_';
25
26    /**
27     * Whether the current user can renew the plan the state describes.
28     *
29     * A viewer with no WordPress.com identity never is. A failed lookup reads
30     * as owner: a stale "Renew now" costs one refused checkout, hiding it costs the site.
31     *
32     * @param array<string,mixed> $state State from Expiry_Data::get_expiry_state().
33     */
34    public static function current_user_is_owner( array $state ): bool {
35        $viewer_id = self::current_user_wpcom_id();
36        if ( null === $viewer_id ) {
37            return false;
38        }
39
40        $owner_id = self::owner_id( $state );
41        return null === $owner_id || $owner_id === $viewer_id;
42    }
43
44    /**
45     * The current user's WordPress.com user ID, or null when they have none.
46     *
47     * On Atomic the ID comes from the SSO user meta or the connection token;
48     * null is an admin created on the site itself.
49     */
50    public static function current_user_wpcom_id(): ?int {
51        $user_id = get_current_user_id();
52        if ( ! $user_id ) {
53            return null;
54        }
55
56        if ( Constants::is_true( 'IS_WPCOM' ) ) {
57            return $user_id;
58        }
59
60        $wpcom_user_id = get_user_meta( $user_id, 'wpcom_user_id', true );
61        if ( is_numeric( $wpcom_user_id ) && (int) $wpcom_user_id > 0 ) {
62            return (int) $wpcom_user_id;
63        }
64
65        $user_data = ( new Connection_Manager() )->get_connected_user_data( $user_id );
66        if ( ! is_array( $user_data ) || empty( $user_data['ID'] ) || ! is_numeric( $user_data['ID'] ) ) {
67            return null;
68        }
69        return (int) $user_data['ID'];
70    }
71
72    /**
73     * The WordPress.com user ID of the account that bought the plan, or null
74     * when it cannot be established.
75     *
76     * @param array<string,mixed> $state State from Expiry_Data::get_expiry_state().
77     */
78    public static function owner_id( array $state ): ?int {
79        $subscription_id = (string) ( $state['subscription_id'] ?? '' );
80        $product_slug    = (string) ( $state['product_slug'] ?? '' );
81        if ( '' === $subscription_id && '' === $product_slug ) {
82            return null;
83        }
84
85        $owner_id = Expiry_Wpcom::remember(
86            self::cache_key( $state ),
87            static function () use ( $subscription_id, $product_slug ): ?string {
88                $upgrades = Constants::is_true( 'IS_WPCOM' )
89                    ? self::simple_site_upgrades()
90                    : Expiry_Wpcom::get_as_blog( '/upgrades?site=%d' );
91                $owner_id = is_array( $upgrades ) ? self::pick_owner_id( $upgrades, $subscription_id, $product_slug ) : null;
92                return null === $owner_id ? null : (string) $owner_id;
93            }
94        );
95
96        return null === $owner_id ? null : (int) $owner_id;
97    }
98
99    /**
100     * Where the owner of the plan a state describes is remembered.
101     *
102     * Keyed by subscription so a renewal that issues a new one, possibly to a
103     * different account, starts from a clean answer.
104     *
105     * @param array<string,mixed> $state State from Expiry_Data::get_expiry_state().
106     */
107    public static function cache_key( array $state ): string {
108        $subscription_id = (string) ( $state['subscription_id'] ?? '' );
109        return self::CACHE_KEY_PREFIX . ( '' !== $subscription_id ? $subscription_id : (string) ( $state['product_slug'] ?? '' ) );
110    }
111
112    /**
113     * The owner of one subscription out of a site's upgrade list.
114     *
115     * Matched on the subscription ID, which is what the store refuses renewals
116     * against; a purchase synced before the site knew its ID falls back to the
117     * product slug, which on a one-plan site names the same subscription.
118     *
119     * @param array<int,mixed> $upgrades        Upgrade objects as `/upgrades?site=` returns them.
120     * @param string           $subscription_id The subscription to find, or '' when unknown.
121     * @param string           $product_slug    Fallback match when the ID is unknown.
122     */
123    public static function pick_owner_id( array $upgrades, string $subscription_id, string $product_slug ): ?int {
124        foreach ( $upgrades as $upgrade ) {
125            if ( ! is_object( $upgrade ) || ! self::upgrade_matches( $upgrade, $subscription_id, $product_slug ) ) {
126                continue;
127            }
128            if ( ! isset( $upgrade->user_id ) || ! is_numeric( $upgrade->user_id ) || (int) $upgrade->user_id <= 0 ) {
129                return null;
130            }
131            return (int) $upgrade->user_id;
132        }
133
134        return null;
135    }
136
137    /**
138     * Whether an upgrade entry is the subscription being looked for.
139     *
140     * @param object $upgrade         Upgrade object.
141     * @param string $subscription_id The subscription to find, or '' when unknown.
142     * @param string $product_slug    Fallback match when the ID is unknown.
143     */
144    private static function upgrade_matches( object $upgrade, string $subscription_id, string $product_slug ): bool {
145        if ( '' !== $subscription_id ) {
146            return isset( $upgrade->ID ) && (string) $upgrade->ID === $subscription_id;
147        }
148        return '' !== $product_slug && isset( $upgrade->product_slug ) && $upgrade->product_slug === $product_slug;
149    }
150
151    /**
152     * The site's subscriptions, read straight from the store on Simple.
153     *
154     * The same objects the endpoint serialises for Atomic. The store class
155     * ships only on WordPress.com and is not loaded on its own.
156     *
157     * @return array<int,object>|null Null where the store cannot be read.
158     */
159    private static function simple_site_upgrades(): ?array {
160        if ( ! function_exists( 'get_wpcom_blog_id' ) || ! defined( 'WP_CONTENT_DIR' ) ) {
161            return null;
162        }
163
164        $billing_loader = WP_CONTENT_DIR . '/admin-plugins/wpcom-billing.php';
165        if ( ! is_readable( $billing_loader ) ) {
166            return null;
167        }
168
169        try {
170            require_once $billing_loader;
171            if ( ! class_exists( '\WPCOM_Store_API' ) || ! method_exists( '\WPCOM_Store_API', 'get_site_billing_upgrades' ) ) {
172                return null;
173            }
174            // @phan-suppress-next-line PhanUndeclaredStaticMethod -- wpcom-only, guarded above.
175            $upgrades = \WPCOM_Store_API::get_site_billing_upgrades( (int) get_wpcom_blog_id(), true );
176        } catch ( \Throwable $e ) {
177            return null;
178        }
179
180        return is_array( $upgrades ) ? $upgrades : null;
181    }
182}