Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.75% covered (success)
96.75%
119 / 123
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Expiry_Data
96.72% covered (success)
96.72%
118 / 122
90.91% covered (success)
90.91%
10 / 11
54
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%
30 / 30
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
 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_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        $is_monthly     = false !== stripos( $product_slug, 'monthly' );
109
110        // The raw flag is the customer's intent and stays on for a subscription
111        // billing can no longer charge. The effective answer is a store query on
112        // Simple, so it is only asked inside this plan's own notice window.
113        $raw_auto_renew  = ! empty( $purchase->user_allows_auto_renew ?? $purchase->auto_renew ?? null );
114        $in_notice_range = $days_remaining <= ( $is_monthly ? self::MONTHLY_NOTICE_DAYS : self::ANNUAL_NOTICE_DAYS );
115        $will_renew      = $in_notice_range
116            ? ( self::might_still_auto_renew( $purchase ) ?? $raw_auto_renew )
117            : $raw_auto_renew;
118
119        if ( $days_remaining < 0 ) {
120            // Still in the purchases list, so billing still takes a renewal.
121            $state = self::STATE_EXPIRED_GRACE;
122        } elseif ( ! $in_notice_range ) {
123            $state = self::STATE_ACTIVE;
124        } elseif ( ! $will_renew ) {
125            $state = self::STATE_APPROACHING;
126        } else {
127            // A plan still expected to renew has nothing to hear until a
128            // scheduled attempt has passed without renewing it. Monthly terms
129            // attempt only on the expiry date itself, so they never do.
130            $attempt_has_failed = ! $is_monthly && self::is_past_first_auto_renew_attempt( $purchase, $now );
131            $state              = $attempt_has_failed ? self::STATE_APPROACHING : self::STATE_ACTIVE;
132        }
133
134        return array(
135            'state'           => $state,
136            'expiry_ts'       => $expiry_ts,
137            'days_remaining'  => $days_remaining,
138            'product_slug'    => $product_slug,
139            // Empty on an Atomic site whose synced purchases predate the field.
140            'subscription_id' => isset( $purchase->subscription_id ) && is_scalar( $purchase->subscription_id ) ? (string) $purchase->subscription_id : '',
141            // Whether a renewal is still expected, once past active; outside the
142            // notice window this is the raw flag. Don't trust it on an active plan.
143            'auto_renew'      => $will_renew,
144        );
145    }
146
147    /**
148     * The post-grace state of a site reverted for an expired plan, or null when
149     * the revert was for something else or support can no longer restore it.
150     *
151     * @param array<string,mixed> $revert `reverted_at` timestamp and `for_expired_plan` flag.
152     * @param int|null            $now    Timestamp to judge against. Defaults to time().
153     * @return array<string,mixed>|null
154     */
155    public static function compute_state_from_revert( array $revert, ?int $now = null ): ?array {
156        if ( empty( $revert['for_expired_plan'] ) || empty( $revert['reverted_at'] ) ) {
157            return null;
158        }
159        $reverted_at = (int) $revert['reverted_at'];
160        $now       ??= time();
161        $days_since = max( 0, (int) floor( ( $now - $reverted_at ) / DAY_IN_SECONDS ) );
162        if ( $days_since >= self::POST_GRACE_PERIOD_DAYS ) {
163            return null;
164        }
165
166        return array(
167            'state'           => self::STATE_EXPIRED,
168            'expiry_ts'       => $reverted_at,
169            'days_remaining'  => -$days_since,
170            'product_slug'    => '',
171            'subscription_id' => '',
172            'auto_renew'      => false,
173        );
174    }
175
176    /**
177     * Whether billing still expects to renew this purchase, or null when the
178     * purchase shape cannot say (Atomic purchases synced before it existed).
179     *
180     * @param object $purchase Purchase object.
181     */
182    private static function might_still_auto_renew( $purchase ): ?bool {
183        if ( ! method_exists( $purchase, 'might_still_auto_renew' ) ) {
184            return null;
185        }
186        $might_still_auto_renew = $purchase->might_still_auto_renew();
187        return is_bool( $might_still_auto_renew ) ? $might_still_auto_renew : null;
188    }
189
190    /**
191     * Whether the first scheduled auto-renewal attempt is behind us.
192     *
193     * A date compared to our clock, not a synced boolean: Atomic purchases stay
194     * frozen until the next subscription event. Unknown reads as not yet.
195     *
196     * @param object $purchase Purchase object.
197     * @param int    $now      Timestamp to compare against.
198     */
199    private static function is_past_first_auto_renew_attempt( $purchase, int $now ): bool {
200        if ( ! method_exists( $purchase, 'first_auto_renew_attempt_date' ) ) {
201            return false;
202        }
203
204        $attempt_date = $purchase->first_auto_renew_attempt_date();
205        if ( ! is_string( $attempt_date ) || '' === $attempt_date ) {
206            return false;
207        }
208
209        $attempt_ts = strtotime( $attempt_date );
210        return false !== $attempt_ts && $attempt_ts < $now;
211    }
212
213    /**
214     * The plan's localized short name, or null where the Plans package can't say.
215     *
216     * Ask only when copy is about to name the plan: on Atomic the Plans package
217     * fetches the whole plan list from WordPress.com to answer, and on Simple it
218     * loads the billing stack. Remembered per locale.
219     *
220     * @param string $slug Product slug.
221     */
222    public static function derive_plan_name( string $slug ): ?string {
223        if ( '' === $slug || ! method_exists( '\Automattic\Jetpack\Plans', 'get_plan_short_name' ) ) {
224            return null;
225        }
226        return Expiry_Wpcom::remember(
227            'wpcom_expiry_notices_plan_name_' . $slug . '_' . get_user_locale(),
228            static function () use ( $slug ): ?string {
229                $short_name = \Automattic\Jetpack\Plans::get_plan_short_name( $slug );
230                return is_string( $short_name ) && '' !== $short_name ? $short_name : null;
231            }
232        );
233    }
234
235    /**
236     * The canonical plan class a slug belongs to, or null when it isn't a plan.
237     *
238     * @param string $slug Product slug.
239     * @return string|null One of 'personal', 'premium', 'business', 'commerce', 'pro'.
240     */
241    private static function infer_plan_class_from_slug( string $slug ): ?string {
242        if ( '' === $slug ) {
243            return null;
244        }
245        if ( false !== strpos( $slug, 'personal' ) ) {
246            return 'personal';
247        }
248        if ( false !== strpos( $slug, 'value_bundle' ) || 'bundle_pro' === $slug || false !== strpos( $slug, 'premium' ) ) {
249            return 'premium';
250        }
251        if ( false !== strpos( $slug, 'ecommerce' ) || false !== strpos( $slug, 'commerce' ) ) {
252            return 'commerce';
253        }
254        if ( false !== strpos( $slug, 'business' ) ) {
255            return 'business';
256        }
257        if ( false !== strpos( $slug, 'pro' ) ) {
258            return 'pro';
259        }
260        return null;
261    }
262
263    /**
264     * Storage included with the plan, in GB, or null when unknown. Mirrors
265     * Calypso's plan-expiry-notice storage map so both quote the same figure.
266     *
267     * @param string $slug Product slug.
268     */
269    public static function get_plan_storage_gb( string $slug ): ?int {
270        $storage_by_class = array(
271            'personal' => 6,
272            'premium'  => 13,
273            'business' => 50,
274            'commerce' => 50,
275        );
276        return $storage_by_class[ self::infer_plan_class_from_slug( $slug ) ?? '' ] ?? null;
277    }
278
279    /**
280     * CTA URLs for the current expiry state.
281     *
282     * @param array<string,mixed> $state       State as produced by compute_state_from_purchase().
283     * @param string              $redirect_to Optional URL checkout returns the user to.
284     * @return array{primary:array{label:string,url:string},secondary:array{label:string,url:string}}
285     */
286    public static function get_cta_urls( array $state, string $redirect_to = '' ): array {
287        $domain          = (string) wpcom_get_site_slug();
288        $slug            = isset( $state['product_slug'] ) ? (string) $state['product_slug'] : '';
289        $subscription_id = isset( $state['subscription_id'] ) ? (string) $state['subscription_id'] : '';
290
291        // Naming the subscription makes checkout a renewal the cart refuses for
292        // anyone but its owner; the plain form would quietly become a second
293        // purchase of the plan for another admin.
294        $primary = array(
295            'label' => __( 'Renew now', 'jetpack-mu-wpcom' ),
296            'url'   => '' === $subscription_id
297                ? sprintf( 'https://wordpress.com/checkout/%s/%s', $slug, $domain )
298                : sprintf( 'https://wordpress.com/checkout/%s/renew/%s/%s', $slug, $subscription_id, $domain ),
299        );
300        // add_query_arg() does not encode, and a redirect with a query of its
301        // own would hand checkout the second half as parameters.
302        if ( '' !== $redirect_to ) {
303            $primary['url'] = add_query_arg( 'redirect_to', rawurlencode( $redirect_to ), $primary['url'] );
304        }
305
306        return array(
307            'primary'   => $primary,
308            'secondary' => array(
309                'label' => __( 'View other plans', 'jetpack-mu-wpcom' ),
310                'url'   => sprintf( 'https://wordpress.com/plans/%s', $domain ),
311            ),
312        );
313    }
314}