Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.96% covered (success)
92.96%
198 / 213
79.31% covered (warning)
79.31%
23 / 29
CRAP
n/a
0 / 0
wpcom_expiry_get_purchases
n/a
0 / 0
n/a
0 / 0
2
wpcom_expiry_get_reverted_transfer
n/a
0 / 0
n/a
0 / 0
18
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%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
wpcom_expiry_notices_store_is_sandboxed
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
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
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
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%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
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%
29 / 29
100.00% covered (success)
100.00%
1 / 1
14
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
3
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_register_meta_for_request
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
wpcom_expiry_notices_dismiss
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
wpcom_expiry_notices_ajax_dismiss
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
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;
12use Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices\Expiry_Wpcom;
13
14// @codeCoverageIgnoreStart
15require_once __DIR__ . '/class-expiry-data.php';
16require_once __DIR__ . '/class-expiry-domain.php';
17require_once __DIR__ . '/class-expiry-notice-dismiss.php';
18require_once __DIR__ . '/class-expiry-owner.php';
19require_once __DIR__ . '/class-expiry-wpcom.php';
20// @codeCoverageIgnoreEnd
21
22// @codeCoverageIgnoreStart -- shadowed by the test stub in tests/lib/functions-wordpress.php.
23if ( ! function_exists( 'wpcom_expiry_get_purchases' ) ) {
24    /**
25     * Source of purchase data for the expiry-notices feature. Pre-definable
26     * by a test mu-plugin without redeclaring `wpcom_get_site_purchases()`
27     * (which has no `function_exists` guard upstream and would fatal).
28     *
29     * @return array
30     *
31     * @phan-suppress PhanRedefineFunction -- phan sees both this and the test stub as definitions even though only one loads at runtime.
32     */
33    function wpcom_expiry_get_purchases() {
34        if ( function_exists( 'wpcom_get_site_purchases' ) ) {
35            return wpcom_get_site_purchases();
36        }
37        return array();
38    }
39}
40
41if ( ! function_exists( 'wpcom_expiry_get_reverted_transfer' ) ) {
42    /**
43     * The site's latest revert, when it was the automatic one that follows an
44     * expired plan: when it happened, and whether it qualifies. Null on a site
45     * that is Atomic, was never reverted, or where the WordPress.com helpers
46     * are not loaded. Pre-definable by a test mu-plugin.
47     *
48     * @return array{reverted_at:int,for_expired_plan:bool}|null
49     *
50     * @phan-suppress PhanRedefineFunction -- phan sees both this and the test stub as definitions even though only one loads at runtime.
51     */
52    function wpcom_expiry_get_reverted_transfer(): ?array {
53        if ( ! function_exists( 'get_wpcom_blog_id' ) || ! function_exists( 'woa_get_latest_transfer' ) || ! function_exists( 'woa_get_transfer_meta' ) || ! function_exists( 'woa_is_revert_for_expired_plan' ) ) {
54            return null;
55        }
56        $blog_id = get_wpcom_blog_id();
57        // A site that was never reverted must never pay for a transient, so the
58        // sticker is checked before the cache, not inside its lookup.
59        if ( ! $blog_id || ! wpcom_has_blog_sticker( 'blog-transfer-reverted', $blog_id ) ) {
60            return null;
61        }
62
63        $cached = Expiry_Wpcom::remember(
64            'wpcom_expiry_notices_reverted_transfer_' . $blog_id,
65            static function () use ( $blog_id ): ?string {
66                // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only, guarded by function_exists() above.
67                $transfer = woa_get_latest_transfer( $blog_id );
68                if ( ! is_object( $transfer ) || is_wp_error( $transfer ) || 'reverted' !== (string) ( $transfer->status ?? '' ) ) {
69                    return Expiry_Wpcom::NONE;
70                }
71                $transfer_id = (int) ( $transfer->atomic_transfer_id ?? 0 );
72                // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only, guarded by function_exists() above.
73                $reverted_at = $transfer_id ? woa_get_transfer_meta( $transfer_id, 'reverted_at' ) : null;
74                $reverted_ts = is_string( $reverted_at ) ? strtotime( $reverted_at ) : false;
75                if ( false === $reverted_ts ) {
76                    return Expiry_Wpcom::NONE;
77                }
78                return wp_json_encode(
79                    array(
80                        'reverted_at'      => $reverted_ts,
81                        // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom-only, guarded by function_exists() above.
82                        'for_expired_plan' => (bool) woa_is_revert_for_expired_plan( $transfer_id ),
83                    ),
84                    JSON_UNESCAPED_SLASHES
85                );
86            }
87        );
88
89        if ( null === $cached || Expiry_Wpcom::NONE === $cached ) {
90            return null;
91        }
92        $decoded = json_decode( $cached, true );
93        if ( ! is_array( $decoded ) || ! isset( $decoded['reverted_at'] ) || ! isset( $decoded['for_expired_plan'] ) ) {
94            return null;
95        }
96        return array(
97            'reverted_at'      => (int) $decoded['reverted_at'],
98            'for_expired_plan' => (bool) $decoded['for_expired_plan'],
99        );
100    }
101}
102// @codeCoverageIgnoreEnd
103
104/**
105 * Whether the new expiry notices are on for this site.
106 *
107 * The one predicate both halves of the swap read: the notices themselves, and
108 * the legacy notices that stand down for them. They must never disagree.
109 */
110function wpcom_expiry_notices_is_enabled_for_site(): bool {
111    /**
112     * Filters whether the new expiry notices are enabled for this site.
113     *
114     * @since $$next-version$$
115     *
116     * @param bool $enabled    Whether the site is on the new expiry notices.
117     * @param int  $percentage Always 100 now the rollout is done; kept so two-argument callbacks keep working.
118     */
119    return (bool) apply_filters( 'wpcom_expiry_notices_enabled', true, 100 );
120}
121
122/**
123 * The expiry state this user should be shown something about, or null.
124 *
125 * The audience test every surface shares, memoized because several hooks ask
126 * per request and nothing can change the answer mid-request.
127 *
128 * @param bool $flush Drop the memo (tests only).
129 * @return array<string,mixed>|null
130 */
131function wpcom_expiry_notices_eligible_state( bool $flush = false ): ?array {
132    // Distinct from null, which is a real answer worth remembering.
133    static $memo = false;
134
135    if ( $flush ) {
136        $memo = false;
137        return null;
138    }
139
140    if ( false !== $memo ) {
141        return $memo;
142    }
143
144    $memo = null;
145
146    if ( ! current_user_can( 'manage_options' ) ) {
147        return $memo;
148    }
149
150    // Excluded to match the Simple notice this replaces.
151    if ( function_exists( 'wpcom_is_vip' ) && wpcom_is_vip() ) {
152        return $memo;
153    }
154
155    if ( wpcom_expiry_notices_store_is_sandboxed() ) {
156        return $memo;
157    }
158
159    $state = Expiry_Data::get_expiry_state();
160    if ( null === $state || Expiry_Data::STATE_ACTIVE === $state['state'] ) {
161        return $memo;
162    }
163
164    $memo = $state;
165    return $memo;
166}
167
168/**
169 * Whether this Simple request reads the Store Sandbox instead of the store.
170 *
171 * The sandbox's purchases are test rows nothing renews or expires, so what
172 * they say about expiry is noise; the notices stand down for them. Atomic reads
173 * synced purchases and never sees the sandbox, so this is false there.
174 */
175function wpcom_expiry_notices_store_is_sandboxed(): bool {
176    if ( ! Constants::is_true( 'IS_WPCOM' ) || ! class_exists( 'Store_Sandbox' ) ) {
177        return false;
178    }
179    // @phan-suppress-next-line PhanUndeclaredClassMethod -- wpcom-only, guarded by class_exists().
180    return (bool) \Store_Sandbox::get_instance()->is_sandboxed();
181}
182
183/**
184 * The current admin screen's id, or '' outside wp-admin.
185 */
186function wpcom_expiry_notices_current_screen_id(): string {
187    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
188    return $screen ? (string) $screen->id : '';
189}
190
191/**
192 * Whether the current screen is a block editor: post editor, site editor, or
193 * block widgets.
194 */
195function wpcom_expiry_notices_is_block_editor_screen(): bool {
196    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
197    return $screen ? $screen->is_block_editor() : false;
198}
199
200/**
201 * What a banner surface renders from, or null if none should show.
202 *
203 * The early reminder is a Dashboard-only nudge; every later stage shows on
204 * every wp-admin screen and on the front end. `urls` is null when the viewer
205 * cannot renew.
206 *
207 * @return array{state:array,is_early_warning:bool,is_dismissible:bool,is_owner:bool,urls:array|null}|null
208 */
209function wpcom_expiry_notices_banner_data(): ?array {
210    $state = wpcom_expiry_notices_eligible_state();
211    if ( null === $state || ! Expiry_Notice_Dismiss::should_show_banner( $state ) ) {
212        return null;
213    }
214
215    $is_early_warning = wpcom_expiry_notices_is_early_warning( $state );
216    if ( $is_early_warning && 'dashboard' !== wpcom_expiry_notices_current_screen_id() ) {
217        return null;
218    }
219
220    $is_owner = Expiry_Owner::current_user_is_owner( $state );
221
222    return array(
223        'state'            => $state,
224        'is_early_warning' => $is_early_warning,
225        'is_dismissible'   => Expiry_Notice_Dismiss::is_dismissible( $state ),
226        'is_owner'         => $is_owner,
227        'urls'             => $is_owner ? wpcom_expiry_notices_banner_urls( $state, wpcom_expiry_notices_current_url() ) : null,
228    );
229}
230
231/**
232 * The plan's short name, or '' for the rare purchase whose slug the Plans
233 * package can't resolve; every string has a variant without it.
234 *
235 * Resolved here rather than with the state, so a request that renders nothing
236 * never asks for it.
237 *
238 * @param array<string,mixed> $state Expiry state.
239 */
240function wpcom_expiry_notices_plan_name( array $state ): string {
241    return Expiry_Data::derive_plan_name( (string) ( $state['product_slug'] ?? '' ) ) ?? '';
242}
243
244/**
245 * The "Your {plan} plan has expired" heading, shared by the banner and the modal.
246 *
247 * @param array<string,mixed> $state Expiry state.
248 */
249function wpcom_expiry_notices_expired_heading( array $state ): string {
250    $plan = wpcom_expiry_notices_plan_name( $state );
251    return '' === $plan
252        ? __( 'Your plan has expired', 'jetpack-mu-wpcom' )
253        /* translators: %s is the plan name (e.g. Business). */
254        : sprintf( __( 'Your %s plan has expired', 'jetpack-mu-wpcom' ), $plan );
255}
256
257/**
258 * Whether the revert this feature describes applies to this site, now.
259 *
260 * Post-grace is the revert: the state only exists once the site has been
261 * reverted for its expired plan. Before that, only an Atomic site has a
262 * revert ahead of it.
263 *
264 * @param array<string,mixed> $state Expiry state.
265 */
266function wpcom_expiry_notices_revert_applies_to_site( array $state ): bool {
267    if ( Expiry_Data::STATE_EXPIRED === ( $state['state'] ?? '' ) ) {
268        return true;
269    }
270    return Constants::is_true( 'IS_ATOMIC' );
271}
272
273/**
274 * The CTA a reverted site gets, pointing at support rather than checkout.
275 *
276 * Buying the plan again does not undo the revert, so only support can help with
277 * what the notice says was lost. `message` opens the Help Center with it typed
278 * in; `url` is the fallback for a click the Help Center could not answer.
279 *
280 * @param array<string,mixed> $state Expiry state.
281 * @return array{label:string,url:string,message:string}
282 */
283function wpcom_expiry_notices_support_cta( array $state ): array {
284    $plan = wpcom_expiry_notices_plan_name( $state );
285
286    return array(
287        'label'   => __( 'Contact support', 'jetpack-mu-wpcom' ),
288        'url'     => 'https://wordpress.com/help?help-center=home',
289        // STATE_EXPIRED carries no plan today, so this branch is unreachable from
290        // the revert state; kept for symmetry with the heading and body helpers.
291        'message' => '' === $plan
292            ? __( 'My plan expired and I need your help getting it restored.', 'jetpack-mu-wpcom' )
293            /* translators: %s is the plan name (e.g. Business). */
294            : sprintf( __( 'My %s plan expired and I need your help getting it restored.', 'jetpack-mu-wpcom' ), $plan ),
295    );
296}
297
298/**
299 * The Tracks props every surface's events carry.
300 *
301 * Booleans go as the strings 'true'/'false': the PHP and JS Tracks clients
302 * encode a real boolean differently, and a funnel has to read both the same.
303 *
304 * @param array<string,mixed> $state    Expiry state.
305 * @param bool                $is_owner Whether the viewer can renew, and so was offered a CTA.
306 * @param string              $surface  Where the notice showed.
307 * @return array{state:string,days_remaining:int,product_slug:string,is_plan_owner:string,surface:string}
308 */
309function wpcom_expiry_notices_track_props( array $state, bool $is_owner, string $surface ): array {
310    return array(
311        'state'          => (string) ( $state['state'] ?? '' ),
312        'days_remaining' => isset( $state['days_remaining'] ) ? (int) $state['days_remaining'] : 0,
313        'product_slug'   => isset( $state['product_slug'] ) ? (string) $state['product_slug'] : '',
314        'is_plan_owner'  => $is_owner ? 'true' : 'false',
315        'surface'        => $surface,
316    );
317}
318
319/**
320 * Enqueue a surface's script with its data on `window.$global`, and its stylesheet.
321 *
322 * Inline JSON: wp_localize_script() would hand the client "" for a false.
323 * The Tracks transport rides along because Atomic wp-admin loads none of its own.
324 *
325 * @param string              $script Build entry of the script.
326 * @param string              $global Name of the window property the data lands on.
327 * @param array<string,mixed> $data   What the script renders from.
328 * @param string|null         $style  Build entry of the stylesheet, if any.
329 */
330function wpcom_expiry_notices_enqueue_surface( string $script, string $global, array $data, ?string $style = null ): void {
331    $json = wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS );
332    if ( false === $json ) {
333        return;
334    }
335    $handle = jetpack_mu_wpcom_enqueue_assets( $script, array( 'js' ) );
336    \Automattic\Jetpack\Jetpack_Mu_Wpcom\Common\wpcom_enqueue_tracking_scripts( $handle );
337    $dismiss = wp_json_encode(
338        array(
339            'ajaxUrl' => admin_url( 'admin-ajax.php' ),
340            'nonce'   => wp_create_nonce( 'wpcom_expiry_notice_dismiss' ),
341        ),
342        JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS
343    );
344    wp_add_inline_script( $handle, 'window.wpcomExpiryDismiss = ' . $dismiss . ';', 'before' );
345    wp_add_inline_script( $handle, 'window.' . $global . ' = ' . $json . ';', 'before' );
346    if ( null !== $style ) {
347        jetpack_mu_wpcom_enqueue_assets( $style, array( 'css' ) );
348    }
349}
350
351/**
352 * Print a CTA as a link the banner script tracks, carrying the support
353 * message where there is one.
354 *
355 * @param array<string,string> $cta    Label, url and optional message.
356 * @param string               $cta_id Which CTA: primary or secondary.
357 * @param string               $class  Class attribute.
358 */
359function wpcom_expiry_notices_render_cta_link( array $cta, string $cta_id, string $class ): void {
360    ?>
361    <a
362        class="<?php echo esc_attr( $class ); ?>"
363        href="<?php echo esc_url( $cta['url'] ); ?>"
364        data-wpcom-expiry-cta="<?php echo esc_attr( $cta_id ); ?>"
365        <?php if ( isset( $cta['message'] ) ) : ?>
366            data-support-message="<?php echo esc_attr( $cta['message'] ); ?>"
367        <?php endif; ?>
368    >
369        <?php echo esc_html( $cta['label'] ); ?>
370    </a>
371    <?php
372}
373
374/**
375 * CTA URLs for a banner surface: support once the site is reverted.
376 *
377 * @param array<string,mixed> $state       Expiry state.
378 * @param string              $redirect_to Where checkout sends the user back to.
379 * @return array<string,array>
380 */
381function wpcom_expiry_notices_banner_urls( array $state, string $redirect_to ): array {
382    $urls = Expiry_Data::get_cta_urls( $state, $redirect_to );
383    if ( Expiry_Data::STATE_EXPIRED === ( $state['state'] ?? '' ) ) {
384        $urls['primary'] = wpcom_expiry_notices_support_cta( $state );
385    }
386    return $urls;
387}
388
389/**
390 * Whether this is the early reminder: approaching expiry with more than the
391 * final week to go.
392 *
393 * @param array<string,mixed> $state Expiry state.
394 */
395function wpcom_expiry_notices_is_early_warning( array $state ): bool {
396    if ( Expiry_Data::STATE_APPROACHING !== ( $state['state'] ?? '' ) ) {
397        return false;
398    }
399    $days = isset( $state['days_remaining'] ) ? (int) $state['days_remaining'] : 0;
400    return $days > Expiry_Notice_Dismiss::FINAL_WINDOW_DAYS;
401}
402
403/**
404 * The banner heading: which plan, and how long it has left.
405 *
406 * @param array<string,mixed> $state Expiry state.
407 */
408function wpcom_expiry_notices_banner_heading( array $state ): string {
409    $plan = wpcom_expiry_notices_plan_name( $state );
410    $days = isset( $state['days_remaining'] ) ? (int) $state['days_remaining'] : 0;
411
412    if ( in_array( $state['state'] ?? '', array( Expiry_Data::STATE_EXPIRED_GRACE, Expiry_Data::STATE_EXPIRED ), true ) ) {
413        return wpcom_expiry_notices_expired_heading( $state );
414    }
415
416    // Still expected to renew: a neutral countdown, not the language of a
417    // deadline someone who just switched auto-renew on has already missed.
418    if ( ! empty( $state['auto_renew'] ) && $days > 0 ) {
419        return '' === $plan
420            /* translators: %d is the number of days remaining. */
421            ? sprintf( _n( 'Your plan has %d day remaining', 'Your plan has %d days remaining', $days, 'jetpack-mu-wpcom' ), $days )
422            /* translators: %1$s is the plan name (e.g. Business). %2$d is the number of days remaining. */
423            : 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 );
424    }
425
426    // Never "expired" while billing's day of expiry is still running, even where
427    // the site's calendar has already moved past the date shown.
428    if ( $days <= 0 ) {
429        return '' === $plan
430            ? __( 'Your plan expires today', 'jetpack-mu-wpcom' )
431            /* translators: %s is the plan name (e.g. Business). */
432            : sprintf( __( 'Your %s plan expires today', 'jetpack-mu-wpcom' ), $plan );
433    }
434
435    return '' === $plan
436        /* translators: %d is the number of days remaining. */
437        ? sprintf( _n( 'Your plan expires in %d day', 'Your plan expires in %d days', $days, 'jetpack-mu-wpcom' ), $days )
438        /* translators: %1$s is the plan name (e.g. Business). %2$d is the number of days remaining. */
439        : 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 );
440}
441
442/**
443 * The banner body: what the site loses and what to do about it, or -- for an
444 * admin who cannot renew -- whose plan it is instead.
445 *
446 * The non-owner sentence is the one the Plans page uses, so the two agree.
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_body( array $state, bool $is_owner ): string {
452    if ( ! $is_owner ) {
453        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' );
454    }
455
456    $storage_gb = Expiry_Data::get_plan_storage_gb( isset( $state['product_slug'] ) ? (string) $state['product_slug'] : '' );
457    $days       = isset( $state['days_remaining'] ) ? (int) $state['days_remaining'] : 0;
458    $auto_renew = ! empty( $state['auto_renew'] );
459    $stage      = $state['state'] ?? '';
460
461    if ( Expiry_Data::STATE_EXPIRED === $stage ) {
462        /* translators: %d is a number of gigabytes of storage. */
463        $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' );
464        $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' );
465    } elseif ( Expiry_Data::STATE_EXPIRED_GRACE === $stage && $auto_renew ) {
466        /* translators: %d is a number of gigabytes of storage. */
467        $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' );
468        $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' );
469    } elseif ( Expiry_Data::STATE_EXPIRED_GRACE === $stage ) {
470        /* translators: %d is a number of gigabytes of storage. */
471        $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' );
472        $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' );
473    } elseif ( $auto_renew && $days <= Expiry_Notice_Dismiss::FINAL_WINDOW_DAYS ) {
474        /* translators: %d is a number of gigabytes of storage. */
475        $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' );
476        $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' );
477    } elseif ( $auto_renew ) {
478        /* translators: %d is a number of gigabytes of storage. */
479        $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' );
480        $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' );
481    } elseif ( $days <= 0 ) {
482        /* translators: %d is a number of gigabytes of storage. */
483        $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' );
484        $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' );
485    } elseif ( $days <= Expiry_Notice_Dismiss::FINAL_WINDOW_DAYS ) {
486        /* translators: %d is a number of gigabytes of storage. */
487        $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' );
488        $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' );
489    } else {
490        return wpcom_expiry_notices_early_warning_body( $state, $storage_gb );
491    }
492
493    return null === $storage_gb ? $without_storage : sprintf( $with_storage, $storage_gb );
494}
495
496/**
497 * The early reminder names the date: "in 45 days" is hard to place on a
498 * calendar, and there is still time to plan around it.
499 *
500 * @param array<string,mixed> $state      Expiry state.
501 * @param int|null            $storage_gb Storage the plan includes, or null when unknown.
502 */
503function wpcom_expiry_notices_early_warning_body( array $state, ?int $storage_gb ): string {
504    // The start of billing's UTC day in the site's timezone, as the Purchases
505    // pages show it: west of UTC that is the day before, never a day too late.
506    $expiry_date = (string) wp_date( (string) get_option( 'date_format' ), Expiry_Data::start_of_utc_day( (int) $state['expiry_ts'] ) );
507
508    if ( '' === $expiry_date ) {
509        return null === $storage_gb
510            ? __( 'Your site will move to the Free plan, which means you’ll lose access to plugins, custom themes, and additional storage.', 'jetpack-mu-wpcom' )
511            /* translators: %d is a number of gigabytes of storage. */
512            : 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 );
513    }
514
515    return null === $storage_gb
516        /* translators: %s is the expiration date. */
517        ? 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 )
518        /* translators: %1$s is the expiration date. %2$d is a number of gigabytes of storage. */
519        : 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 );
520}
521
522/**
523 * Heading and body as one run of text, for surfaces with no heading markup.
524 *
525 * @param array<string,mixed> $state    Expiry state.
526 * @param bool                $is_owner Whether the viewer can renew.
527 */
528function wpcom_expiry_notices_banner_sentence( array $state, bool $is_owner ): string {
529    return sprintf(
530        /* translators: %1$s is the notice heading (e.g. "Your plan has expired"), %2$s is the rest of the notice. */
531        __( '%1$s. %2$s', 'jetpack-mu-wpcom' ),
532        wpcom_expiry_notices_banner_heading( $state ),
533        wpcom_expiry_notices_banner_body( $state, $is_owner )
534    );
535}
536
537/**
538 * Load the surfaces for this request, unless something has held this site back.
539 *
540 * On `init`: every hook a surface registers fires later still, so waiting
541 * keeps the requires off the bootstrap at no cost.
542 */
543function wpcom_expiry_notices_maybe_load_surfaces() {
544    if ( ! wpcom_expiry_notices_is_enabled_for_site() ) {
545        return;
546    }
547    if ( is_admin() ) {
548        require_once __DIR__ . '/admin-banner.php';
549        require_once __DIR__ . '/admin-modal.php';
550        require_once __DIR__ . '/editor-notice.php';
551    } else {
552        require_once __DIR__ . '/frontend-banner.php';
553    }
554}
555add_action( 'init', 'wpcom_expiry_notices_maybe_load_surfaces' ); // @codeCoverageIgnore
556
557/**
558 * Whether the front-end banner will render on this request.
559 *
560 * The one predicate the other front-end banners read to stand down. Callable
561 * from `init`, so it does not ask conditional query tags.
562 */
563function wpcom_expiry_notices_frontend_banner_is_due(): bool {
564    if ( is_admin() || is_customize_preview() || ! wpcom_expiry_notices_is_enabled_for_site() ) {
565        return false;
566    }
567    return null !== wpcom_expiry_notices_banner_data();
568}
569
570/**
571 * Take the one front-end banner slot on Simple when the expiry banner is due.
572 *
573 * WordPress.com's resolver shows a single banner per request, picked from a
574 * fixed key list; a key it does not know leaves it nothing to show.
575 *
576 * @param array<string,callable>|mixed $banners Banners registered so far; another callback may have returned a non-array.
577 * @return array<string,callable>|mixed
578 */
579function wpcom_expiry_notices_claim_wpcom_banner_slot( $banners ) {
580    if ( ! is_array( $banners ) || ! wpcom_expiry_notices_frontend_banner_is_due() ) {
581        return $banners;
582    }
583    return array( 'wpcom_expiry_banner' => '__return_null' );
584}
585add_filter( 'wpcom_register_banners', 'wpcom_expiry_notices_claim_wpcom_banner_slot', PHP_INT_MAX ); // @codeCoverageIgnore
586
587/**
588 * Register the dismiss meta keys, off the front end where nothing writes them.
589 */
590function wpcom_expiry_notices_register_meta() {
591    if ( ! is_admin() && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
592        return;
593    }
594    if ( ! wpcom_expiry_notices_is_enabled_for_site() ) {
595        return;
596    }
597    Expiry_Notice_Dismiss::register_user_meta();
598}
599add_action( 'init', 'wpcom_expiry_notices_register_meta' ); // @codeCoverageIgnore
600// `init` alone never registers these on a REST request: REST_REQUEST is defined
601// on `parse_request`, after `init`, and a write to an unregistered key is a
602// silent 200. Every dismissal arrives over REST.
603add_action( 'rest_api_init', 'wpcom_expiry_notices_register_meta' ); // @codeCoverageIgnore
604
605/**
606 * Register the keys once more after the centralized API has switched to the
607 * site. On WordPress.com the `rest_api_init` registration runs on the API's own
608 * blog, before `rest_pre_dispatch` switches to the site a `/sites/{id}/` route
609 * is for, so its keys carry that blog's prefix and the site's own key is
610 * unregistered when the write arrives; core drops it with a 200.
611 *
612 * @param mixed $response Response to replace the request with, or null.
613 * @return mixed
614 */
615function wpcom_expiry_notices_register_meta_for_request( $response ) {
616    wpcom_expiry_notices_register_meta();
617    return $response;
618}
619add_filter( 'rest_request_before_callbacks', 'wpcom_expiry_notices_register_meta_for_request' ); // @codeCoverageIgnore
620
621/**
622 * Stamp a dismissal for a user, as the surfaces ask for it over admin-ajax.
623 *
624 * The site's own admin-ajax rather than the REST API: on WordPress.com the
625 * REST API lives on another origin, and the front end has no proxy to reach
626 * it through. The dashboard still writes the same key over REST.
627 *
628 * @param string $meta_key The key the surface was given.
629 * @param int    $user_id  The dismissing user.
630 * @return array{status:int,body:array<string,string>}
631 */
632function wpcom_expiry_notices_dismiss( string $meta_key, int $user_id ): array {
633    if ( ! wpcom_expiry_notices_is_enabled_for_site() || ! user_can( $user_id, 'manage_options' ) ) {
634        return array(
635            'status' => 403,
636            'body'   => array( 'message' => 'forbidden' ),
637        );
638    }
639    if ( ! Expiry_Notice_Dismiss::dismiss( $user_id, $meta_key ) ) {
640        return array(
641            'status' => 400,
642            'body'   => array( 'message' => 'unknown notice' ),
643        );
644    }
645    return array(
646        'status' => 200,
647        'body'   => array(),
648    );
649}
650
651/**
652 * The admin-ajax action behind wpcom_expiry_notices_dismiss().
653 */
654function wpcom_expiry_notices_ajax_dismiss(): void {
655    check_ajax_referer( 'wpcom_expiry_notice_dismiss' );
656    $meta_key = isset( $_POST['metaKey'] ) ? sanitize_text_field( wp_unslash( $_POST['metaKey'] ) ) : '';
657    $result   = wpcom_expiry_notices_dismiss( $meta_key, get_current_user_id() );
658    $flags    = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS;
659    if ( 200 !== $result['status'] ) {
660        wp_send_json_error( $result['body'], $result['status'], $flags );
661    }
662    wp_send_json_success( $result['body'], $result['status'], $flags );
663}
664add_action( 'wp_ajax_wpcom_expiry_notice_dismiss', 'wpcom_expiry_notices_ajax_dismiss' ); // @codeCoverageIgnore
665
666/**
667 * The URL of the current page, for checkout to send the user back to.
668 *
669 * One-shot query args (`settings-updated`, `updated`, ...) are dropped so the
670 * return trip doesn't replay them.
671 */
672function wpcom_expiry_notices_current_url(): string {
673    $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.
674    $request_uri = remove_query_arg( wp_removable_query_args(), $request_uri );
675
676    if ( is_admin() ) {
677        $admin_path = (string) wp_parse_url( admin_url(), PHP_URL_PATH );
678        return 0 === strpos( $request_uri, $admin_path )
679            ? admin_url( substr( $request_uri, strlen( $admin_path ) ) )
680            : admin_url();
681    }
682    return home_url( '' === $request_uri ? '/' : $request_uri );
683}