Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.75% |
119 / 123 |
|
90.91% |
10 / 11 |
CRAP | |
0.00% |
0 / 1 |
| Expiry_Data | |
96.72% |
118 / 122 |
|
90.91% |
10 / 11 |
54 | |
0.00% |
0 / 1 |
| get_expiry_state | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| pick_primary_plan_purchase | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
| is_plan_purchase | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| compute_state_from_purchase | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
13 | |||
| compute_state_from_revert | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
| might_still_auto_renew | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| is_past_first_auto_renew_attempt | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| derive_plan_name | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| infer_plan_class_from_slug | |
69.23% |
9 / 13 |
|
0.00% |
0 / 1 |
12.91 | |||
| get_plan_storage_gb | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| get_cta_urls | |
100.00% |
18 / 18 |
|
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 | |
| 8 | declare( strict_types = 1 ); |
| 9 | |
| 10 | namespace Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices; |
| 11 | |
| 12 | require_once __DIR__ . '/class-expiry-wpcom.php'; |
| 13 | |
| 14 | /** |
| 15 | * Reads purchases and computes a normalized expiry state for the primary plan. |
| 16 | */ |
| 17 | class 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 | } |