Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.70% covered (success)
95.70%
178 / 186
79.17% covered (warning)
79.17%
19 / 24
CRAP
n/a
0 / 0
wpcom_expiry_get_purchases
n/a
0 / 0
n/a
0 / 0
2
wpcom_expiry_notices_is_enabled_for_site
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcom_expiry_notices_eligible_state
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
wpcom_expiry_notices_current_screen_id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
wpcom_expiry_notices_is_block_editor_screen
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
wpcom_expiry_notices_banner_data
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
wpcom_expiry_notices_plan_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcom_expiry_notices_expired_heading
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
wpcom_expiry_notices_revert_applies_to_site
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
wpcom_expiry_notices_support_cta
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
wpcom_expiry_notices_track_props
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
wpcom_expiry_notices_enqueue_surface
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
wpcom_expiry_notices_render_cta_link
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
wpcom_expiry_notices_banner_urls
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
wpcom_expiry_notices_is_early_warning
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
wpcom_expiry_notices_banner_heading
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
9
wpcom_expiry_notices_banner_body
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
19
wpcom_expiry_notices_early_warning_body
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
wpcom_expiry_notices_banner_sentence
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
wpcom_expiry_notices_maybe_load_surfaces
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
wpcom_expiry_notices_frontend_banner_is_due
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
wpcom_expiry_notices_claim_wpcom_banner_slot
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
wpcom_expiry_notices_register_meta
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
wpcom_expiry_notices_current_url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Sitewide plan-expiry notices: loader, and the helpers every surface shares.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8use Automattic\Jetpack\Constants;
9use Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices\Expiry_Data;
10use Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices\Expiry_Notice_Dismiss;
11use Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices\Expiry_Owner;
12
13// @codeCoverageIgnoreStart
14require_once __DIR__ . '/class-expiry-data.php';
15require_once __DIR__ . '/class-expiry-domain.php';
16require_once __DIR__ . '/class-expiry-notice-dismiss.php';
17require_once __DIR__ . '/class-expiry-owner.php';
18require_once __DIR__ . '/class-expiry-wpcom.php';
19// @codeCoverageIgnoreEnd
20
21// @codeCoverageIgnoreStart -- shadowed by the test stub in tests/lib/functions-wordpress.php.
22if ( ! function_exists( 'wpcom_expiry_get_purchases' ) ) {
23    /**
24     * Source of purchase data for the expiry-notices feature. Pre-definable
25     * by a test mu-plugin without redeclaring `wpcom_get_site_purchases()`
26     * (which has no `function_exists` guard upstream and would fatal).
27     *
28     * @return array
29     *
30     * @phan-suppress PhanRedefineFunction -- phan sees both this and the test stub as definitions even though only one loads at runtime.
31     */
32    function wpcom_expiry_get_purchases() {
33        if ( function_exists( 'wpcom_get_site_purchases' ) ) {
34            return wpcom_get_site_purchases();
35        }
36        return array();
37    }
38}
39// @codeCoverageIgnoreEnd
40
41/**
42 * Whether the new expiry notices are on for this site.
43 *
44 * The one predicate both halves of the swap read: the notices themselves, and
45 * the legacy notices that stand down for them. They must never disagree.
46 */
47function wpcom_expiry_notices_is_enabled_for_site(): bool {
48    /**
49     * Filters whether the new expiry notices are enabled for this site.
50     *
51     * @since $$next-version$$
52     *
53     * @param bool $enabled    Whether the site is on the new expiry notices.
54     * @param int  $percentage Always 100 now the rollout is done; kept so two-argument callbacks keep working.
55     */
56    return (bool) apply_filters( 'wpcom_expiry_notices_enabled', true, 100 );
57}
58
59/**
60 * The expiry state this user should be shown something about, or null.
61 *
62 * The audience test every surface shares, memoized because several hooks ask
63 * per request and nothing can change the answer mid-request.
64 *
65 * @param bool $flush Drop the memo (tests only).
66 * @return array<string,mixed>|null
67 */
68function wpcom_expiry_notices_eligible_state( bool $flush = false ): ?array {
69    // Distinct from null, which is a real answer worth remembering.
70    static $memo = false;
71
72    if ( $flush ) {
73        $memo = false;
74        return null;
75    }
76
77    if ( false !== $memo ) {
78        return $memo;
79    }
80
81    $memo = null;
82
83    if ( ! current_user_can( 'manage_options' ) ) {
84        return $memo;
85    }
86
87    // Excluded to match the Simple notice this replaces.
88    if ( function_exists( 'wpcom_is_vip' ) && wpcom_is_vip() ) {
89        return $memo;
90    }
91
92    $state = Expiry_Data::get_expiry_state();
93    if ( null === $state || Expiry_Data::STATE_ACTIVE === $state['state'] ) {
94        return $memo;
95    }
96
97    $memo = $state;
98    return $memo;
99}
100
101/**
102 * The current admin screen's id, or '' outside wp-admin.
103 */
104function wpcom_expiry_notices_current_screen_id(): string {
105    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
106    return $screen ? (string) $screen->id : '';
107}
108
109/**
110 * Whether the current screen is a block editor: post editor, site editor, or
111 * block widgets.
112 */
113function wpcom_expiry_notices_is_block_editor_screen(): bool {
114    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
115    return $screen ? $screen->is_block_editor() : false;
116}
117
118/**
119 * What a banner surface renders from, or null if none should show.
120 *
121 * The early reminder is a Dashboard-only nudge; every later stage shows on
122 * every wp-admin screen and on the front end. `urls` is null when the viewer
123 * cannot renew.
124 *
125 * @return array{state:array,is_early_warning:bool,is_dismissible:bool,is_owner:bool,urls:array|null}|null
126 */
127function wpcom_expiry_notices_banner_data(): ?array {
128    $state = wpcom_expiry_notices_eligible_state();
129    if ( null === $state || ! Expiry_Notice_Dismiss::should_show_banner( $state ) ) {
130        return null;
131    }
132
133    $is_early_warning = wpcom_expiry_notices_is_early_warning( $state );
134    if ( $is_early_warning && 'dashboard' !== wpcom_expiry_notices_current_screen_id() ) {
135        return null;
136    }
137
138    $is_owner = Expiry_Owner::current_user_is_owner( $state );
139
140    return array(
141        'state'            => $state,
142        'is_early_warning' => $is_early_warning,
143        'is_dismissible'   => Expiry_Notice_Dismiss::is_dismissible( $state ),
144        'is_owner'         => $is_owner,
145        'urls'             => $is_owner ? wpcom_expiry_notices_banner_urls( $state, wpcom_expiry_notices_current_url() ) : null,
146    );
147}
148
149/**
150 * The plan's short name, or '' for the rare purchase whose slug the Plans
151 * package can't resolve; every string has a variant without it.
152 *
153 * Resolved here rather than with the state, so a request that renders nothing
154 * never asks for it.
155 *
156 * @param array<string,mixed> $state Expiry state.
157 */
158function wpcom_expiry_notices_plan_name( array $state ): string {
159    return Expiry_Data::derive_plan_name( (string) ( $state['product_slug'] ?? '' ) ) ?? '';
160}
161
162/**
163 * The "Your {plan} plan has expired" heading, shared by the banner and the modal.
164 *
165 * @param array<string,mixed> $state Expiry state.
166 */
167function wpcom_expiry_notices_expired_heading( array $state ): string {
168    $plan = wpcom_expiry_notices_plan_name( $state );
169    return '' === $plan
170        ? __( 'Your plan has expired', 'jetpack-mu-wpcom' )
171        /* translators: %s is the plan name (e.g. Business). */
172        : sprintf( __( 'Your %s plan has expired', 'jetpack-mu-wpcom' ), $plan );
173}
174
175/**
176 * Whether the revert this feature describes applies to this site, now.
177 *
178 * Past the grace period this waits on the sticker rather than the date: the
179 * revert runs off the subscription-removal record and can lag the state by days.
180 *
181 * @param array<string,mixed> $state Expiry state.
182 */
183function wpcom_expiry_notices_revert_applies_to_site( array $state ): bool {
184    if ( Expiry_Data::STATE_EXPIRED === ( $state['state'] ?? '' ) ) {
185        return wpcom_has_blog_sticker( 'blog-transfer-reverted', get_wpcom_blog_id() );
186    }
187
188    return Constants::is_true( 'IS_ATOMIC' );
189}
190
191/**
192 * The CTA a reverted site gets, pointing at support rather than checkout.
193 *
194 * Buying the plan again does not undo the revert, so only support can help with
195 * what the notice says was lost. `message` opens the Help Center with it typed
196 * in; `url` is the fallback for a click the Help Center could not answer.
197 *
198 * @param array<string,mixed> $state Expiry state.
199 * @return array{label:string,url:string,message:string}
200 */
201function wpcom_expiry_notices_support_cta( array $state ): array {
202    $plan = wpcom_expiry_notices_plan_name( $state );
203
204    return array(
205        'label'   => __( 'Contact support', 'jetpack-mu-wpcom' ),
206        'url'     => 'https://wordpress.com/help?help-center=home',
207        'message' => '' === $plan
208            ? __( 'My plan expired and I need your help getting it restored.', 'jetpack-mu-wpcom' )
209            /* translators: %s is the plan name (e.g. Business). */
210            : sprintf( __( 'My %s plan expired and I need your help getting it restored.', 'jetpack-mu-wpcom' ), $plan ),
211    );
212}
213
214/**
215 * The Tracks props every surface's events carry.
216 *
217 * Booleans go as the strings 'true'/'false': the PHP and JS Tracks clients
218 * encode a real boolean differently, and a funnel has to read both the same.
219 *
220 * @param array<string,mixed> $state    Expiry state.
221 * @param bool                $is_owner Whether the viewer can renew, and so was offered a CTA.
222 * @param string              $surface  Where the notice showed.
223 * @return array{state:string,days_remaining:int,product_slug:string,is_plan_owner:string,surface:string}
224 */
225function wpcom_expiry_notices_track_props( array $state, bool $is_owner, string $surface ): array {
226    return array(
227        'state'          => (string) ( $state['state'] ?? '' ),
228        'days_remaining' => isset( $state['days_remaining'] ) ? (int) $state['days_remaining'] : 0,
229        'product_slug'   => isset( $state['product_slug'] ) ? (string) $state['product_slug'] : '',
230        'is_plan_owner'  => $is_owner ? 'true' : 'false',
231        'surface'        => $surface,
232    );
233}
234
235/**
236 * Enqueue a surface's script with its data on `window.$global`, and its stylesheet.
237 *
238 * Inline JSON: wp_localize_script() would hand the client "" for a false.
239 * The Tracks transport rides along because Atomic wp-admin loads none of its own.
240 *
241 * @param string              $script Build entry of the script.
242 * @param string              $global Name of the window property the data lands on.
243 * @param array<string,mixed> $data   What the script renders from.
244 * @param string|null         $style  Build entry of the stylesheet, if any.
245 */
246function wpcom_expiry_notices_enqueue_surface( string $script, string $global, array $data, ?string $style = null ): void {
247    $json = wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS );
248    if ( false === $json ) {
249        return;
250    }
251    $handle = jetpack_mu_wpcom_enqueue_assets( $script, array( 'js' ) );
252    \Automattic\Jetpack\Jetpack_Mu_Wpcom\Common\wpcom_enqueue_tracking_scripts( $handle );
253    wp_add_inline_script( $handle, 'window.' . $global . ' = ' . $json . ';', 'before' );
254    if ( null !== $style ) {
255        jetpack_mu_wpcom_enqueue_assets( $style, array( 'css' ) );
256    }
257}
258
259/**
260 * Print a CTA as a link the banner script tracks, carrying the support
261 * message where there is one.
262 *
263 * @param array<string,string> $cta    Label, url and optional message.
264 * @param string               $cta_id Which CTA: primary or secondary.
265 * @param string               $class  Class attribute.
266 */
267function wpcom_expiry_notices_render_cta_link( array $cta, string $cta_id, string $class ): void {
268    ?>
269    <a
270        class="<?php echo esc_attr( $class ); ?>"
271        href="<?php echo esc_url( $cta['url'] ); ?>"
272        data-wpcom-expiry-cta="<?php echo esc_attr( $cta_id ); ?>"
273        <?php if ( isset( $cta['message'] ) ) : ?>
274            data-support-message="<?php echo esc_attr( $cta['message'] ); ?>"
275        <?php endif; ?>
276    >
277        <?php echo esc_html( $cta['label'] ); ?>
278    </a>
279    <?php
280}
281
282/**
283 * CTA URLs for a banner surface: support once the site is reverted, a
284 * "Restore site" checkout for one that only lost plan features.
285 *
286 * @param array<string,mixed> $state       Expiry state.
287 * @param string              $redirect_to Where checkout sends the user back to.
288 * @return array<string,array>
289 */
290function wpcom_expiry_notices_banner_urls( array $state, string $redirect_to ): array {
291    $urls = Expiry_Data::get_cta_urls( $state, $redirect_to );
292    if ( Expiry_Data::STATE_EXPIRED !== ( $state['state'] ?? '' ) ) {
293        return $urls;
294    }
295
296    if ( wpcom_expiry_notices_revert_applies_to_site( $state ) ) {
297        $urls['primary'] = wpcom_expiry_notices_support_cta( $state );
298    } else {
299        $urls['primary']['label'] = __( 'Restore site', 'jetpack-mu-wpcom' );
300    }
301    return $urls;
302}
303
304/**
305 * Whether this is the early reminder: approaching expiry with more than the
306 * final week to go.
307 *
308 * @param array<string,mixed> $state Expiry state.
309 */
310function wpcom_expiry_notices_is_early_warning( array $state ): bool {
311    if ( Expiry_Data::STATE_APPROACHING !== ( $state['state'] ?? '' ) ) {
312        return false;
313    }
314    $days = isset( $state['days_remaining'] ) ? (int) $state['days_remaining'] : 0;
315    return $days > Expiry_Notice_Dismiss::FINAL_WINDOW_DAYS;
316}
317
318/**
319 * The banner heading: which plan, and how long it has left.
320 *
321 * @param array<string,mixed> $state Expiry state.
322 */
323function wpcom_expiry_notices_banner_heading( array $state ): string {
324    $plan = wpcom_expiry_notices_plan_name( $state );
325    $days = isset( $state['days_remaining'] ) ? (int) $state['days_remaining'] : 0;
326
327    if ( in_array( $state['state'] ?? '', array( Expiry_Data::STATE_EXPIRED_GRACE, Expiry_Data::STATE_EXPIRED ), true ) ) {
328        return wpcom_expiry_notices_expired_heading( $state );
329    }
330
331    // Still expected to renew: a neutral countdown, not the language of a
332    // deadline someone who just switched auto-renew on has already missed.
333    if ( ! empty( $state['auto_renew'] ) && $days > 0 ) {
334        return '' === $plan
335            /* translators: %d is the number of days remaining. */
336            ? sprintf( _n( 'Your plan has %d day remaining', 'Your plan has %d days remaining', $days, 'jetpack-mu-wpcom' ), $days )
337            /* translators: %1$s is the plan name (e.g. Business). %2$d is the number of days remaining. */
338            : sprintf( _n( 'Your %1$s plan has %2$d day remaining', 'Your %1$s plan has %2$d days remaining', $days, 'jetpack-mu-wpcom' ), $plan, $days );
339    }
340
341    // Never "expired" while the day of expiry is still running.
342    if ( 0 === $days ) {
343        return '' === $plan
344            ? __( 'Your plan expires today', 'jetpack-mu-wpcom' )
345            /* translators: %s is the plan name (e.g. Business). */
346            : sprintf( __( 'Your %s plan expires today', 'jetpack-mu-wpcom' ), $plan );
347    }
348
349    return '' === $plan
350        /* translators: %d is the number of days remaining. */
351        ? sprintf( _n( 'Your plan expires in %d day', 'Your plan expires in %d days', $days, 'jetpack-mu-wpcom' ), $days )
352        /* translators: %1$s is the plan name (e.g. Business). %2$d is the number of days remaining. */
353        : sprintf( _n( 'Your %1$s plan expires in %2$d day', 'Your %1$s plan expires in %2$d days', $days, 'jetpack-mu-wpcom' ), $plan, $days );
354}
355
356/**
357 * The banner body: what the site loses and what to do about it, or -- for an
358 * admin who cannot renew -- whose plan it is instead.
359 *
360 * The non-owner sentence is the one the Plans page uses, so the two agree.
361 *
362 * @param array<string,mixed> $state    Expiry state.
363 * @param bool                $is_owner Whether the viewer can renew.
364 */
365function wpcom_expiry_notices_banner_body( array $state, bool $is_owner ): string {
366    if ( ! $is_owner ) {
367        return __( 'This plan was purchased by a different WordPress.com account. To manage this plan, log in to that account or contact the account owner.', 'jetpack-mu-wpcom' );
368    }
369
370    $storage_gb  = Expiry_Data::get_plan_storage_gb( isset( $state['product_slug'] ) ? (string) $state['product_slug'] : '' );
371    $days        = isset( $state['days_remaining'] ) ? (int) $state['days_remaining'] : 0;
372    $auto_renew  = ! empty( $state['auto_renew'] );
373    $stage       = $state['state'] ?? '';
374    $is_reverted = Expiry_Data::STATE_EXPIRED === $stage && wpcom_expiry_notices_revert_applies_to_site( $state );
375
376    // Past grace by the calendar but still Atomic and un-reverted: none of what
377    // the post-grace copy claims has happened yet, and renewing still prevents it.
378    if ( Expiry_Data::STATE_EXPIRED === $stage && ! $is_reverted && Constants::is_true( 'IS_ATOMIC' ) ) {
379        $stage = Expiry_Data::STATE_EXPIRED_GRACE;
380    }
381
382    if ( $is_reverted ) {
383        /* translators: %d is a number of gigabytes of storage. */
384        $with_storage    = __( 'Your site has been moved to the Free plan and set to private. You no longer have access to plugins, custom themes, or %d GB of storage. Contact support to get help restoring it.', 'jetpack-mu-wpcom' );
385        $without_storage = __( 'Your site has been moved to the Free plan and set to private. You no longer have access to plugins, custom themes, or additional storage. Contact support to get help restoring it.', 'jetpack-mu-wpcom' );
386    } elseif ( Expiry_Data::STATE_EXPIRED === $stage ) {
387        /* translators: %d is a number of gigabytes of storage. */
388        $with_storage    = __( 'Your site has been moved to the Free plan. You no longer have access to plugins, custom themes, or %d GB of storage. Upgrade your plan to restore your site.', 'jetpack-mu-wpcom' );
389        $without_storage = __( 'Your site has been moved to the Free plan. You no longer have access to plugins, custom themes, or additional storage. Upgrade your plan to restore your site.', 'jetpack-mu-wpcom' );
390    } elseif ( Expiry_Data::STATE_EXPIRED_GRACE === $stage && $auto_renew ) {
391        /* translators: %d is a number of gigabytes of storage. */
392        $with_storage    = __( 'If renewal doesn’t go through, your site will move to the Free plan. That means losing plugins, custom themes, and %d GB of storage. But it’s not too late. Renew now to keep your site as it is.', 'jetpack-mu-wpcom' );
393        $without_storage = __( 'If renewal doesn’t go through, your site will move to the Free plan. That means losing plugins, custom themes, and additional storage. But it’s not too late. Renew now to keep your site as it is.', 'jetpack-mu-wpcom' );
394    } elseif ( Expiry_Data::STATE_EXPIRED_GRACE === $stage ) {
395        /* translators: %d is a number of gigabytes of storage. */
396        $with_storage    = __( 'Your site will move to the Free plan. That means losing plugins, custom themes, and %d GB of storage. But it’s not too late. Renew now to keep your site as it is.', 'jetpack-mu-wpcom' );
397        $without_storage = __( 'Your site will move to the Free plan. That means losing plugins, custom themes, and additional storage. But it’s not too late. Renew now to keep your site as it is.', 'jetpack-mu-wpcom' );
398    } elseif ( $auto_renew && $days <= Expiry_Notice_Dismiss::FINAL_WINDOW_DAYS ) {
399        /* translators: %d is a number of gigabytes of storage. */
400        $with_storage    = __( 'If renewal doesn’t go through, your site will move to the Free plan and you’ll lose plugins, custom themes, and %d GB of storage. Renew now to keep everything in place.', 'jetpack-mu-wpcom' );
401        $without_storage = __( 'If renewal doesn’t go through, your site will move to the Free plan and you’ll lose plugins, custom themes, and additional storage. Renew now to keep everything in place.', 'jetpack-mu-wpcom' );
402    } elseif ( $auto_renew ) {
403        /* translators: %d is a number of gigabytes of storage. */
404        $with_storage    = __( 'If renewal doesn’t go through, your site will move to the Free plan, and you’ll lose access to plugins, custom themes, and %d GB of storage.', 'jetpack-mu-wpcom' );
405        $without_storage = __( 'If renewal doesn’t go through, your site will move to the Free plan, and you’ll lose access to plugins, custom themes, and additional storage.', 'jetpack-mu-wpcom' );
406    } elseif ( 0 === $days ) {
407        /* translators: %d is a number of gigabytes of storage. */
408        $with_storage    = __( 'Unless you renew your plan, your site will move to the Free plan, and you’ll lose plugins, custom themes, and %d GB of storage. Renew now to keep everything in place.', 'jetpack-mu-wpcom' );
409        $without_storage = __( 'Unless you renew your plan, your site will move to the Free plan, and you’ll lose plugins, custom themes, and additional storage. Renew now to keep everything in place.', 'jetpack-mu-wpcom' );
410    } elseif ( $days <= Expiry_Notice_Dismiss::FINAL_WINDOW_DAYS ) {
411        /* translators: %d is a number of gigabytes of storage. */
412        $with_storage    = __( 'Your site will move to the Free plan and you’ll lose plugins, custom themes, and %d GB of storage. Renew now to keep everything in place.', 'jetpack-mu-wpcom' );
413        $without_storage = __( 'Your site will move to the Free plan and you’ll lose plugins, custom themes, and additional storage. Renew now to keep everything in place.', 'jetpack-mu-wpcom' );
414    } else {
415        return wpcom_expiry_notices_early_warning_body( $state, $storage_gb );
416    }
417
418    return null === $storage_gb ? $without_storage : sprintf( $with_storage, $storage_gb );
419}
420
421/**
422 * The early reminder names the date: "in 45 days" is hard to place on a
423 * calendar, and there is still time to plan around it.
424 *
425 * @param array<string,mixed> $state      Expiry state.
426 * @param int|null            $storage_gb Storage the plan includes, or null when unknown.
427 */
428function wpcom_expiry_notices_early_warning_body( array $state, ?int $storage_gb ): string {
429    $expiry_date = (string) wp_date( (string) get_option( 'date_format' ), (int) $state['expiry_ts'] );
430
431    if ( '' === $expiry_date ) {
432        return null === $storage_gb
433            ? __( 'Your site will move to the Free plan, which means you’ll lose access to plugins, custom themes, and additional storage.', 'jetpack-mu-wpcom' )
434            /* translators: %d is a number of gigabytes of storage. */
435            : sprintf( __( 'Your site will move to the Free plan, which means you’ll lose access to plugins, custom themes, and %d GB of storage.', 'jetpack-mu-wpcom' ), $storage_gb );
436    }
437
438    return null === $storage_gb
439        /* translators: %s is the expiration date. */
440        ? sprintf( __( 'After %s, your site will move to the Free plan, which means you’ll lose access to plugins, custom themes, and additional storage.', 'jetpack-mu-wpcom' ), $expiry_date )
441        /* translators: %1$s is the expiration date. %2$d is a number of gigabytes of storage. */
442        : sprintf( __( 'After %1$s, your site will move to the Free plan, which means you’ll lose access to plugins, custom themes, and %2$d GB of storage.', 'jetpack-mu-wpcom' ), $expiry_date, $storage_gb );
443}
444
445/**
446 * Heading and body as one run of text, for surfaces with no heading markup.
447 *
448 * @param array<string,mixed> $state    Expiry state.
449 * @param bool                $is_owner Whether the viewer can renew.
450 */
451function wpcom_expiry_notices_banner_sentence( array $state, bool $is_owner ): string {
452    return sprintf(
453        /* translators: %1$s is the notice heading (e.g. "Your plan has expired"), %2$s is the rest of the notice. */
454        __( '%1$s. %2$s', 'jetpack-mu-wpcom' ),
455        wpcom_expiry_notices_banner_heading( $state ),
456        wpcom_expiry_notices_banner_body( $state, $is_owner )
457    );
458}
459
460/**
461 * Load the surfaces for this request, unless something has held this site back.
462 *
463 * On `init`: every hook a surface registers fires later still, so waiting
464 * keeps the requires off the bootstrap at no cost.
465 */
466function wpcom_expiry_notices_maybe_load_surfaces() {
467    if ( ! wpcom_expiry_notices_is_enabled_for_site() ) {
468        return;
469    }
470    if ( is_admin() ) {
471        require_once __DIR__ . '/admin-banner.php';
472        require_once __DIR__ . '/admin-modal.php';
473        require_once __DIR__ . '/editor-notice.php';
474    } else {
475        require_once __DIR__ . '/frontend-banner.php';
476    }
477}
478add_action( 'init', 'wpcom_expiry_notices_maybe_load_surfaces' ); // @codeCoverageIgnore
479
480/**
481 * Whether the front-end banner will render on this request.
482 *
483 * The one predicate the other front-end banners read to stand down. Callable
484 * from `init`, so it does not ask conditional query tags.
485 */
486function wpcom_expiry_notices_frontend_banner_is_due(): bool {
487    if ( is_admin() || is_customize_preview() || ! wpcom_expiry_notices_is_enabled_for_site() ) {
488        return false;
489    }
490    return null !== wpcom_expiry_notices_banner_data();
491}
492
493/**
494 * Take the one front-end banner slot on Simple when the expiry banner is due.
495 *
496 * WordPress.com's resolver shows a single banner per request, picked from a
497 * fixed key list; a key it does not know leaves it nothing to show.
498 *
499 * @param array<string,callable> $banners Banners registered so far.
500 * @return array<string,callable>
501 */
502function wpcom_expiry_notices_claim_wpcom_banner_slot( array $banners ): array {
503    if ( ! wpcom_expiry_notices_frontend_banner_is_due() ) {
504        return $banners;
505    }
506    return array( 'wpcom_expiry_banner' => '__return_null' );
507}
508add_filter( 'wpcom_register_banners', 'wpcom_expiry_notices_claim_wpcom_banner_slot', PHP_INT_MAX ); // @codeCoverageIgnore
509
510/**
511 * Register the dismiss meta keys, off the front end where nothing writes them.
512 */
513function wpcom_expiry_notices_register_meta() {
514    if ( ! is_admin() && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
515        return;
516    }
517    if ( ! wpcom_expiry_notices_is_enabled_for_site() ) {
518        return;
519    }
520    Expiry_Notice_Dismiss::register_user_meta();
521}
522add_action( 'init', 'wpcom_expiry_notices_register_meta' ); // @codeCoverageIgnore
523// `init` alone never registers these on a REST request: REST_REQUEST is defined
524// on `parse_request`, after `init`, and a write to an unregistered key is a
525// silent 200. Every dismissal arrives over REST.
526add_action( 'rest_api_init', 'wpcom_expiry_notices_register_meta' ); // @codeCoverageIgnore
527
528/**
529 * The URL of the current page, for checkout to send the user back to.
530 *
531 * One-shot query args (`settings-updated`, `updated`, ...) are dropped so the
532 * return trip doesn't replay them.
533 */
534function wpcom_expiry_notices_current_url(): string {
535    $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- isset() is the validation; the sniff wants a second one.
536    $request_uri = remove_query_arg( wp_removable_query_args(), $request_uri );
537
538    if ( is_admin() ) {
539        $admin_path = (string) wp_parse_url( admin_url(), PHP_URL_PATH );
540        return 0 === strpos( $request_uri, $admin_path )
541            ? admin_url( substr( $request_uri, strlen( $admin_path ) ) )
542            : admin_url();
543    }
544    return home_url( '' === $request_uri ? '/' : $request_uri );
545}