Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.21% |
114 / 121 |
|
72.73% |
8 / 11 |
CRAP | |
0.00% |
0 / 1 |
| Expiry_Data | |
94.21% |
114 / 121 |
|
72.73% |
8 / 11 |
52.52 | |
0.00% |
0 / 1 |
| get_expiry_state | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| pick_primary_plan_purchase | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
| is_plan_purchase | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
4.13 | |||
| compute_state_from_purchase | |
100.00% |
46 / 46 |
|
100.00% |
1 / 1 |
14 | |||
| 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 | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
5.39 | |||
| infer_plan_class_from_slug | |
61.54% |
8 / 13 |
|
0.00% |
0 / 1 |
15.69 | |||
| is_monthly_plan | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_plan_storage_gb | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| get_cta_urls | |
100.00% |
16 / 16 |
|
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 | |
| 8 | declare( strict_types = 1 ); |
| 9 | |
| 10 | namespace Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices; |
| 11 | |
| 12 | /** |
| 13 | * Reads purchases and computes a normalized expiry state for the primary plan. |
| 14 | */ |
| 15 | class 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 | } |