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