Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.21% covered (success)
94.21%
114 / 121
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Expiry_Data
94.21% covered (success)
94.21%
114 / 121
72.73% covered (warning)
72.73%
8 / 11
52.52
0.00% covered (danger)
0.00%
0 / 1
 get_expiry_state
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 pick_primary_plan_purchase
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 is_plan_purchase
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 compute_state_from_purchase
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
14
 might_still_auto_renew
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 is_past_first_auto_renew_attempt
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 derive_plan_name
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
5.39
 infer_plan_class_from_slug
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
15.69
 is_monthly_plan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_plan_storage_gb
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 get_cta_urls
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Expiry_Data: derives plan-expiry state from the site's active purchases.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8declare( strict_types = 1 );
9
10namespace Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices;
11
12/**
13 * Reads purchases and computes a normalized expiry state for the primary plan.
14 */
15class Expiry_Data {
16
17    const STATE_ACTIVE        = 'active';
18    const STATE_APPROACHING   = 'approaching_expiry';
19    const STATE_EXPIRED_GRACE = 'expired_grace';
20    const STATE_EXPIRED       = 'expired';
21
22    const GRACE_PERIOD_DAYS      = 30;
23    const POST_GRACE_PERIOD_DAYS = 30;
24    const ANNUAL_NOTICE_DAYS     = 60;
25    const MONTHLY_NOTICE_DAYS    = 7;
26
27    /**
28     * Return the expiry state for the current site, or null if there's no
29     * notice-eligible plan purchase.
30     *
31     * @return array<string,mixed>|null
32     */
33    public static function get_expiry_state(): ?array {
34        $purchases = wpcom_expiry_get_purchases();
35        $plan      = self::pick_primary_plan_purchase( $purchases );
36        if ( null === $plan ) {
37            return null;
38        }
39        return self::compute_state_from_purchase( $plan );
40    }
41
42    /**
43     * Pick the plan purchase with the latest expiry. Non-plan purchases
44     * (addons, domains) are filtered out.
45     *
46     * @param array<int,object>|null $purchases List of purchase objects.
47     * @return object|null
48     */
49    public static function pick_primary_plan_purchase( $purchases ): ?object {
50        if ( empty( $purchases ) ) {
51            return null;
52        }
53
54        $plans = array_filter( (array) $purchases, array( self::class, 'is_plan_purchase' ) );
55        if ( empty( $plans ) ) {
56            return null;
57        }
58
59        usort(
60            $plans,
61            static function ( $a, $b ): int {
62                return strtotime( $b->expiry_date ) <=> strtotime( $a->expiry_date );
63            }
64        );
65
66        return reset( $plans );
67    }
68
69    /**
70     * True if the purchase looks like a site plan, as opposed to an addon or domain.
71     *
72     * Primary signal is `product_type === 'bundle'`. Falls back to slug-based
73     * inference for fixtures missing product_type.
74     *
75     * @param object $purchase Purchase object.
76     */
77    public static function is_plan_purchase( $purchase ): bool {
78        if ( isset( $purchase->product_type ) && 'bundle' === $purchase->product_type ) {
79            return true;
80        }
81        if ( isset( $purchase->product_slug ) ) {
82            return null !== self::infer_plan_class_from_slug( (string) $purchase->product_slug );
83        }
84        return false;
85    }
86
87    /**
88     * Pure: derive normalized state from a single plan purchase. Tests can pass
89     * $now to avoid clock issues.
90     *
91     * @param object   $purchase Purchase object (see wpcom_get_site_purchases() shape).
92     * @param int|null $now      Optional "now" timestamp. Defaults to time().
93     * @return array<string,mixed>|null
94     */
95    public static function compute_state_from_purchase( $purchase, ?int $now = null ): ?array {
96        if ( empty( $purchase->expiry_date ) || empty( $purchase->product_slug ) ) {
97            return null;
98        }
99
100        $expiry_ts = strtotime( (string) $purchase->expiry_date );
101        if ( false === $expiry_ts ) {
102            return null;
103        }
104
105        $now          ??= time();
106        $days_remaining = (int) floor( ( $expiry_ts - $now ) / DAY_IN_SECONDS );
107        $product_slug   = (string) $purchase->product_slug;
108        $plan_name      = self::derive_plan_name( $product_slug );
109        $is_monthly     = self::is_monthly_plan( $product_slug );
110        // The raw flag is only the customer's intent: it stays true for a
111        // subscription that cannot actually be charged. `might_still_auto_renew()`
112        // is the effective answer, but it is null until a site is serving the
113        // declared purchase shape, so fall back to the flag there.
114        $raw_auto_renew = ! empty( $purchase->user_allows_auto_renew ?? $purchase->auto_renew ?? null );
115        $is_atomic      = defined( 'IS_ATOMIC' ) && IS_ATOMIC;
116
117        // Neither the effective renewal state nor the attempt schedule is a
118        // free read: on a Simple site each one queries the store and pulls in
119        // the whole billing stack, on every admin pageview. Outside this plan's
120        // own notice window neither can change the answer, so don't ask. Per
121        // cadence rather than the widest of the two, because a monthly plan
122        // that is still weeks out lands on STATE_ACTIVE either way: it is
123        // excluded from the attempt-passed warning outright, and its
124        // auto-renew-off window is only the last 7 days.
125        $notice_window   = $is_monthly ? self::MONTHLY_NOTICE_DAYS : self::ANNUAL_NOTICE_DAYS;
126        $in_notice_range = $days_remaining <= $notice_window;
127        $will_renew      = $in_notice_range
128            ? ( self::might_still_auto_renew( $purchase ) ?? $raw_auto_renew )
129            : $raw_auto_renew;
130
131        if ( $days_remaining >= 0 ) {
132            $grace_days_left = null;
133
134            if ( ! $in_notice_range ) {
135                // Too early for either warning: the auto-renew-off window has
136                // not opened, and the first renewal attempt is at most 30 days
137                // before expiry, so it cannot already have passed. This has to
138                // come before the branch below, which would otherwise reach
139                // billing for the attempt date and discard it.
140                $state = self::STATE_ACTIVE;
141            } elseif ( $will_renew ) {
142                // Nothing to say about a plan that is still expected to renew
143                // itself, until a scheduled attempt has come and gone without
144                // renewing it. Monthly terms are excluded outright: their first
145                // attempt doesn't land until some unknown hour of the expiry
146                // date, so there is never a "an attempt failed" window to warn
147                // about beforehand.
148                $state = ( ! $is_monthly && self::is_past_first_auto_renew_attempt( $purchase, $now ) )
149                    ? self::STATE_APPROACHING
150                    : self::STATE_ACTIVE;
151            } else {
152                // Inside the window with no renewal expected: the plan is
153                // going to lapse unless the owner acts. Nothing left to test
154                // -- being here already means $days_remaining is within this
155                // plan's own notice window.
156                $state = self::STATE_APPROACHING;
157            }
158        } else {
159            $days_past = abs( $days_remaining );
160            if ( $days_past >= self::GRACE_PERIOD_DAYS + self::POST_GRACE_PERIOD_DAYS ) {
161                return null;
162            }
163            if ( $days_past < self::GRACE_PERIOD_DAYS ) {
164                $state           = self::STATE_EXPIRED_GRACE;
165                $grace_days_left = self::GRACE_PERIOD_DAYS - $days_past;
166            } else {
167                $state           = self::STATE_EXPIRED;
168                $grace_days_left = 0;
169            }
170        }
171
172        return array(
173            'state'           => $state,
174            'expiry_date'     => (string) $purchase->expiry_date,
175            'expiry_ts'       => $expiry_ts,
176            'days_remaining'  => $days_remaining,
177            'grace_days_left' => $grace_days_left,
178            'is_atomic'       => $is_atomic,
179            'is_monthly'      => $is_monthly,
180            'plan_name'       => $plan_name,
181            'product_slug'    => $product_slug,
182            // Whether a renewal is still expected to go through, not merely
183            // whether the customer left auto-renew switched on -- but only
184            // once the state is past active. Outside the notice window this
185            // falls back to the customer's raw flag, which stays true for a
186            // subscription that can no longer be charged, because the
187            // effective answer costs a billing query no notice would have
188            // read. Don't trust this field on an active plan.
189            'auto_renew'      => $will_renew,
190        );
191    }
192
193    /**
194     * Whether the billing system still expects to renew this purchase, or null
195     * when the site has not been told.
196     *
197     * Null covers Atomic sites whose synced purchases predate the declared
198     * shape, and any caller passing a plain purchase row.
199     *
200     * @param object $purchase Purchase object.
201     */
202    private static function might_still_auto_renew( $purchase ): ?bool {
203        if ( ! method_exists( $purchase, 'might_still_auto_renew' ) ) {
204            return null;
205        }
206        $might_still_auto_renew = $purchase->might_still_auto_renew();
207        return is_bool( $might_still_auto_renew ) ? $might_still_auto_renew : null;
208    }
209
210    /**
211     * Whether the first scheduled auto-renewal attempt is behind us.
212     *
213     * Compared against our own clock rather than read as a boolean, because an
214     * Atomic site holds its synced purchases until the next subscription event
215     * and a boolean would have been frozen when it was sent. False whenever the
216     * schedule is unknown, so an unanswered question keeps the notice quiet.
217     *
218     * @param object $purchase Purchase object.
219     * @param int    $now      Timestamp to compare against.
220     */
221    private static function is_past_first_auto_renew_attempt( $purchase, int $now ): bool {
222        if ( ! method_exists( $purchase, 'first_auto_renew_attempt_date' ) ) {
223            return false;
224        }
225
226        $attempt_date = $purchase->first_auto_renew_attempt_date();
227        if ( ! is_string( $attempt_date ) || '' === $attempt_date ) {
228            return false;
229        }
230
231        $attempt_ts = strtotime( $attempt_date );
232        return false !== $attempt_ts && $attempt_ts < $now;
233    }
234
235    /**
236     * Resolve the canonical localized short name for a plan slug. Returns null
237     * if the Plans package isn't loaded (i.e. outside wpcom contexts).
238     *
239     * @param string $slug Product slug.
240     */
241    public static function derive_plan_name( string $slug ): ?string {
242        if ( '' === $slug || ! class_exists( '\Automattic\Jetpack\Plans' ) ) {
243            return null;
244        }
245        $short_name = \Automattic\Jetpack\Plans::get_plan_short_name( $slug );
246        return is_string( $short_name ) && '' !== $short_name ? $short_name : null;
247    }
248
249    /**
250     * Map a product slug to one of the canonical plan classes
251     * ('personal' / 'premium' / 'business' / 'commerce' / 'pro') or null when
252     * the slug doesn't match a plan.
253     *
254     * @param string $slug Product slug.
255     */
256    private static function infer_plan_class_from_slug( string $slug ): ?string {
257        if ( '' === $slug ) {
258            return null;
259        }
260        if ( false !== strpos( $slug, 'personal' ) ) {
261            return 'personal';
262        }
263        if ( false !== strpos( $slug, 'value_bundle' ) || 'bundle_pro' === $slug || false !== strpos( $slug, 'premium' ) ) {
264            return 'premium';
265        }
266        if ( false !== strpos( $slug, 'ecommerce' ) || false !== strpos( $slug, 'commerce' ) ) {
267            return 'commerce';
268        }
269        if ( false !== strpos( $slug, 'business' ) ) {
270            return 'business';
271        }
272        if ( false !== strpos( $slug, 'pro' ) ) {
273            return 'pro';
274        }
275        return null;
276    }
277
278    /**
279     * True if the slug refers to a monthly cadence plan.
280     *
281     * @param string $slug Product slug.
282     */
283    public static function is_monthly_plan( string $slug ): bool {
284        return false !== stripos( $slug, 'monthly' );
285    }
286
287    /**
288     * Storage included with the plan, in GB, or null when unknown. Mirrors
289     * Calypso's plan-expiry-notice storage map so both surfaces quote the same
290     * figure.
291     *
292     * @param string $slug Product slug.
293     */
294    public static function get_plan_storage_gb( string $slug ): ?int {
295        $storage_by_class = array(
296            'personal' => 6,
297            'premium'  => 13,
298            'business' => 50,
299            'commerce' => 50,
300        );
301        $plan_class       = self::infer_plan_class_from_slug( $slug );
302        return null === $plan_class ? null : ( $storage_by_class[ $plan_class ] ?? null );
303    }
304
305    /**
306     * Build CTA URLs for the current expiry state.
307     *
308     * @param array<string,mixed> $state       State as produced by compute_state_from_purchase().
309     * @param string              $redirect_to Optional URL appended to the primary CTA so checkout
310     *                                         can return the user to where they came from.
311     * @return array{primary:array{label:string,url:string},secondary:array{label:string,url:string}}
312     */
313    public static function get_cta_urls( array $state, string $redirect_to = '' ): array {
314        $domain = (string) wpcom_get_site_slug();
315        $slug   = isset( $state['product_slug'] ) ? (string) $state['product_slug'] : '';
316
317        $primary = array(
318            'label' => __( 'Renew now', 'jetpack-mu-wpcom' ),
319            'url'   => sprintf( 'https://wordpress.com/checkout/%s/%s', $slug, $domain ),
320        );
321        if ( '' !== $redirect_to ) {
322            $primary['url'] = add_query_arg( 'redirect_to', $redirect_to, $primary['url'] );
323        }
324
325        $secondary = array(
326            'label' => __( 'View other plans', 'jetpack-mu-wpcom' ),
327            'url'   => sprintf( 'https://wordpress.com/plans/%s', $domain ),
328        );
329
330        return array(
331            'primary'   => $primary,
332            'secondary' => $secondary,
333        );
334    }
335}