Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.95% covered (success)
96.95%
127 / 131
92.86% covered (success)
92.86%
13 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Expiry_Data
96.92% covered (success)
96.92%
126 / 130
92.86% covered (success)
92.86%
13 / 14
57
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
3
 pick_primary_plan_purchase
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 is_plan_purchase
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 compute_state_from_purchase
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
13
 compute_state_from_revert
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 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
 start_of_utc_day
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 end_of_utc_day
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 calendar_days_until
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 derive_plan_name
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 infer_plan_class_from_slug
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
12.91
 get_plan_storage_gb
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 get_cta_urls
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Expiry_Data: derives plan-expiry state from the site's active purchases, or from its revert once the plan is gone.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8declare( strict_types = 1 );
9
10namespace Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices;
11
12require_once __DIR__ . '/class-expiry-wpcom.php';
13
14/**
15 * Reads purchases and computes a normalized expiry state for the primary plan.
16 */
17class Expiry_Data {
18
19    const STATE_ACTIVE        = 'active';
20    const STATE_APPROACHING   = 'approaching_expiry';
21    const STATE_EXPIRED_GRACE = 'expired_grace';
22    const STATE_EXPIRED       = 'expired';
23
24    // How long after the automatic revert the site can still be restored by support.
25    const POST_GRACE_PERIOD_DAYS = 30;
26    const ANNUAL_NOTICE_DAYS     = 60;
27    const MONTHLY_NOTICE_DAYS    = 7;
28
29    /**
30     * The expiry state for the current site, or null if there's nothing to say.
31     *
32     * A plan purchase decides the states before and during grace; once billing
33     * has removed it, only the site's automatic revert can say the plan lapsed.
34     *
35     * @return array<string,mixed>|null
36     */
37    public static function get_expiry_state(): ?array {
38        $plan = self::pick_primary_plan_purchase( wpcom_expiry_get_purchases() );
39        if ( null !== $plan ) {
40            return self::compute_state_from_purchase( $plan );
41        }
42        $revert = wpcom_expiry_get_reverted_transfer();
43        return null === $revert ? null : self::compute_state_from_revert( $revert );
44    }
45
46    /**
47     * The plan purchase with the latest expiry; add-ons and domains are skipped.
48     *
49     * @param array<int,object>|null $purchases List of purchase objects.
50     * @return object|null
51     */
52    public static function pick_primary_plan_purchase( $purchases ): ?object {
53        $plans = array_filter( (array) $purchases, array( self::class, 'is_plan_purchase' ) );
54        if ( empty( $plans ) ) {
55            return null;
56        }
57
58        usort(
59            $plans,
60            static function ( $a, $b ): int {
61                return strtotime( (string) ( $b->expiry_date ?? '' ) ) <=> strtotime( (string) ( $a->expiry_date ?? '' ) );
62            }
63        );
64
65        return reset( $plans );
66    }
67
68    /**
69     * Whether the purchase is a site plan rather than an add-on or domain.
70     *
71     * The slug is only consulted for a purchase synced without a product type:
72     * matching on it alone would take "sensei_pro" or "woocommerce_*" for a plan.
73     *
74     * @param object $purchase Purchase object.
75     */
76    public static function is_plan_purchase( $purchase ): bool {
77        if ( ! empty( $purchase->product_type ) ) {
78            return 'bundle' === $purchase->product_type;
79        }
80        return isset( $purchase->product_slug )
81            && null !== self::infer_plan_class_from_slug( (string) $purchase->product_slug );
82    }
83
84    /**
85     * The normalized state of one plan purchase, or null when it is unusable.
86     *
87     * A purchase still present is billing's to renew however long ago it
88     * expired, so there is no day limit on the grace state here; only the
89     * revert, once the purchase is gone, can end it.
90     *
91     * @param object   $purchase Purchase object (see wpcom_get_site_purchases() shape).
92     * @param int|null $now      Timestamp to judge against. 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_start = strtotime( (string) $purchase->expiry_date );
101        if ( false === $expiry_start ) {
102            return null;
103        }
104        $expiry_day = self::start_of_utc_day( $expiry_start );
105        $expiry_ts  = self::end_of_utc_day( $expiry_start );
106
107        $now          ??= time();
108        $days_remaining = self::calendar_days_until( $expiry_day, $now );
109        $product_slug   = (string) $purchase->product_slug;
110        $is_monthly     = false !== stripos( $product_slug, 'monthly' );
111
112        // The raw flag is the customer's intent and stays on for a subscription
113        // billing can no longer charge. The effective answer is a store query on
114        // Simple, so it is only asked inside this plan's own notice window.
115        $raw_auto_renew  = ! empty( $purchase->user_allows_auto_renew ?? $purchase->auto_renew ?? null );
116        $in_notice_range = $days_remaining <= ( $is_monthly ? self::MONTHLY_NOTICE_DAYS : self::ANNUAL_NOTICE_DAYS );
117        $will_renew      = $in_notice_range
118            ? ( self::might_still_auto_renew( $purchase ) ?? $raw_auto_renew )
119            : $raw_auto_renew;
120
121        if ( $expiry_ts < $now ) {
122            // Still in the purchases list, so billing still takes a renewal.
123            $state = self::STATE_EXPIRED_GRACE;
124        } elseif ( ! $in_notice_range ) {
125            $state = self::STATE_ACTIVE;
126        } elseif ( ! $will_renew ) {
127            $state = self::STATE_APPROACHING;
128        } else {
129            // A plan still expected to renew has nothing to hear until a
130            // scheduled attempt has passed without renewing it. Monthly terms
131            // attempt only on the expiry date itself, so they never do.
132            $attempt_has_failed = ! $is_monthly && self::is_past_first_auto_renew_attempt( $purchase, $now );
133            $state              = $attempt_has_failed ? self::STATE_APPROACHING : self::STATE_ACTIVE;
134        }
135
136        return array(
137            'state'           => $state,
138            'expiry_ts'       => $expiry_ts,
139            'days_remaining'  => $days_remaining,
140            'product_slug'    => $product_slug,
141            // Empty on an Atomic site whose synced purchases predate the field.
142            'subscription_id' => isset( $purchase->subscription_id ) && is_scalar( $purchase->subscription_id ) ? (string) $purchase->subscription_id : '',
143            // Whether a renewal is still expected, once past active; outside the
144            // notice window this is the raw flag. Don't trust it on an active plan.
145            'auto_renew'      => $will_renew,
146        );
147    }
148
149    /**
150     * The post-grace state of a site reverted for an expired plan, or null when
151     * the revert was for something else or support can no longer restore it.
152     *
153     * @param array<string,mixed> $revert `reverted_at` timestamp and `for_expired_plan` flag.
154     * @param int|null            $now    Timestamp to judge against. Defaults to time().
155     * @return array<string,mixed>|null
156     */
157    public static function compute_state_from_revert( array $revert, ?int $now = null ): ?array {
158        if ( empty( $revert['for_expired_plan'] ) || empty( $revert['reverted_at'] ) ) {
159            return null;
160        }
161        $reverted_at = (int) $revert['reverted_at'];
162        $now       ??= time();
163        $days_since = max( 0, (int) floor( ( $now - $reverted_at ) / DAY_IN_SECONDS ) );
164        if ( $days_since >= self::POST_GRACE_PERIOD_DAYS ) {
165            return null;
166        }
167
168        return array(
169            'state'           => self::STATE_EXPIRED,
170            'expiry_ts'       => $reverted_at,
171            'days_remaining'  => -$days_since,
172            'product_slug'    => '',
173            'subscription_id' => '',
174            'auto_renew'      => false,
175        );
176    }
177
178    /**
179     * Whether billing still expects to renew this purchase, or null when the
180     * purchase shape cannot say (Atomic purchases synced before it existed).
181     *
182     * @param object $purchase Purchase object.
183     */
184    private static function might_still_auto_renew( $purchase ): ?bool {
185        if ( ! method_exists( $purchase, 'might_still_auto_renew' ) ) {
186            return null;
187        }
188        $might_still_auto_renew = $purchase->might_still_auto_renew();
189        return is_bool( $might_still_auto_renew ) ? $might_still_auto_renew : null;
190    }
191
192    /**
193     * Whether the first scheduled auto-renewal attempt is behind us.
194     *
195     * A date compared to our clock, not a synced boolean: Atomic purchases stay
196     * frozen until the next subscription event. Unknown reads as not yet.
197     *
198     * @param object $purchase Purchase object.
199     * @param int    $now      Timestamp to compare against.
200     */
201    private static function is_past_first_auto_renew_attempt( $purchase, int $now ): bool {
202        if ( ! method_exists( $purchase, 'first_auto_renew_attempt_date' ) ) {
203            return false;
204        }
205
206        $attempt_date = $purchase->first_auto_renew_attempt_date();
207        if ( ! is_string( $attempt_date ) || '' === $attempt_date ) {
208            return false;
209        }
210
211        $attempt_ts = strtotime( $attempt_date );
212        return false !== $attempt_ts && self::end_of_utc_day( $attempt_ts ) < $now;
213    }
214
215    /**
216     * The first second of the UTC day a timestamp falls on: the instant a
217     * billing date names, and the one to display it by.
218     *
219     * @param int $timestamp Timestamp.
220     */
221    public static function start_of_utc_day( int $timestamp ): int {
222        return $timestamp - ( $timestamp % DAY_IN_SECONDS );
223    }
224
225    /**
226     * The last second of the UTC day a timestamp falls on.
227     *
228     * Billing only counts one of its dates as passed once that whole day is
229     * over, as `Store_Subscription` does.
230     *
231     * @param int $timestamp Timestamp.
232     */
233    private static function end_of_utc_day( int $timestamp ): int {
234        return self::start_of_utc_day( $timestamp ) + DAY_IN_SECONDS - 1;
235    }
236
237    /**
238     * Calendar days in the site's timezone from `$now` to `$timestamp`, as the
239     * Dashboard counts them, so the count agrees with the date shown beside it.
240     *
241     * @param int $timestamp Timestamp to count to.
242     * @param int $now       Timestamp to count from.
243     */
244    private static function calendar_days_until( int $timestamp, int $now ): int {
245        $timezone = wp_timezone();
246        $today    = ( new \DateTimeImmutable( '@' . $now ) )->setTimezone( $timezone )->setTime( 0, 0 );
247        $day      = ( new \DateTimeImmutable( '@' . $timestamp ) )->setTimezone( $timezone )->setTime( 0, 0 );
248        return (int) $today->diff( $day )->format( '%r%a' );
249    }
250
251    /**
252     * The plan's localized short name, or null where the Plans package can't say.
253     *
254     * Ask only when copy is about to name the plan: on Atomic the Plans package
255     * fetches the whole plan list from WordPress.com to answer, and on Simple it
256     * loads the billing stack. Remembered per locale.
257     *
258     * @param string $slug Product slug.
259     */
260    public static function derive_plan_name( string $slug ): ?string {
261        if ( '' === $slug || ! method_exists( '\Automattic\Jetpack\Plans', 'get_plan_short_name' ) ) {
262            return null;
263        }
264        return Expiry_Wpcom::remember(
265            'wpcom_expiry_notices_plan_name_' . $slug . '_' . get_user_locale(),
266            static function () use ( $slug ): ?string {
267                $short_name = \Automattic\Jetpack\Plans::get_plan_short_name( $slug );
268                return is_string( $short_name ) && '' !== $short_name ? $short_name : null;
269            }
270        );
271    }
272
273    /**
274     * The canonical plan class a slug belongs to, or null when it isn't a plan.
275     *
276     * @param string $slug Product slug.
277     * @return string|null One of 'personal', 'premium', 'business', 'commerce', 'pro'.
278     */
279    private static function infer_plan_class_from_slug( string $slug ): ?string {
280        if ( '' === $slug ) {
281            return null;
282        }
283        if ( false !== strpos( $slug, 'personal' ) ) {
284            return 'personal';
285        }
286        if ( false !== strpos( $slug, 'value_bundle' ) || 'bundle_pro' === $slug || false !== strpos( $slug, 'premium' ) ) {
287            return 'premium';
288        }
289        if ( false !== strpos( $slug, 'ecommerce' ) || false !== strpos( $slug, 'commerce' ) ) {
290            return 'commerce';
291        }
292        if ( false !== strpos( $slug, 'business' ) ) {
293            return 'business';
294        }
295        if ( false !== strpos( $slug, 'pro' ) ) {
296            return 'pro';
297        }
298        return null;
299    }
300
301    /**
302     * Storage included with the plan, in GB, or null when unknown. Mirrors
303     * Calypso's plan-expiry-notice storage map so both quote the same figure.
304     *
305     * @param string $slug Product slug.
306     */
307    public static function get_plan_storage_gb( string $slug ): ?int {
308        $storage_by_class = array(
309            'personal' => 6,
310            'premium'  => 13,
311            'business' => 50,
312            'commerce' => 50,
313        );
314        return $storage_by_class[ self::infer_plan_class_from_slug( $slug ) ?? '' ] ?? null;
315    }
316
317    /**
318     * CTA URLs for the current expiry state.
319     *
320     * @param array<string,mixed> $state       State as produced by compute_state_from_purchase().
321     * @param string              $redirect_to Optional URL checkout returns the user to.
322     * @return array{primary:array{label:string,url:string},secondary:array{label:string,url:string}}
323     */
324    public static function get_cta_urls( array $state, string $redirect_to = '' ): array {
325        $domain          = (string) wpcom_get_site_slug();
326        $slug            = isset( $state['product_slug'] ) ? (string) $state['product_slug'] : '';
327        $subscription_id = isset( $state['subscription_id'] ) ? (string) $state['subscription_id'] : '';
328
329        // Naming the subscription makes checkout a renewal the cart refuses for
330        // anyone but its owner; the plain form would quietly become a second
331        // purchase of the plan for another admin.
332        $primary = array(
333            'label' => __( 'Renew now', 'jetpack-mu-wpcom' ),
334            'url'   => '' === $subscription_id
335                ? sprintf( 'https://wordpress.com/checkout/%s/%s', $slug, $domain )
336                : sprintf( 'https://wordpress.com/checkout/%s/renew/%s/%s', $slug, $subscription_id, $domain ),
337        );
338        // add_query_arg() does not encode, and a redirect with a query of its
339        // own would hand checkout the second half as parameters.
340        if ( '' !== $redirect_to ) {
341            $primary['url'] = add_query_arg( 'redirect_to', rawurlencode( $redirect_to ), $primary['url'] );
342        }
343
344        return array(
345            'primary'   => $primary,
346            'secondary' => array(
347                'label' => __( 'View other plans', 'jetpack-mu-wpcom' ),
348                'url'   => sprintf( 'https://wordpress.com/plans/%s', $domain ),
349            ),
350        );
351    }
352}