Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.40% covered (success)
98.40%
184 / 187
84.21% covered (warning)
84.21%
16 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Marketplace_Catalog
98.40% covered (success)
98.40%
184 / 187
84.21% covered (warning)
84.21%
16 / 19
81
0.00% covered (danger)
0.00%
0 / 1
 get_products
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 to_catalog
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 get_product
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_product_details
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 to_details
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 to_screenshots_html
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 to_modal_html
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 request
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 to_card
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
5
 fetch_store_products
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 blog_id
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 attach_pricing
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 is_referral
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 yearly_saving
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 to_category
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 to_variation_ids
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 checkout_url
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 referral_url
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 product_url
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * The WordPress.com marketplace catalog, shaped for core's plugin browser.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8namespace Automattic\Jetpack\Jetpack_Mu_Wpcom;
9
10use Automattic\Jetpack\Connection\Client;
11
12/**
13 * Reads the plugins WordPress.com sells and normalizes them into the array shape
14 * `WP_Plugin_Install_List_Table` expects to get back from `plugins_api()`.
15 *
16 * Descriptions are most of the payload and are only read by the details modal, so
17 * they are dropped from the cached list and re-fetched per product when it opens.
18 */
19class Marketplace_Catalog {
20
21    /**
22     * Bumped whenever the shape of a cached card or description changes, so sites
23     * do not keep serving data built by the previous version until it expires.
24     */
25    const CACHE_VERSION = 8;
26
27    /**
28     * Transient holding the normalized product list.
29     */
30    const LIST_CACHE_KEY = 'wpcom_marketplace_catalog_v' . self::CACHE_VERSION;
31
32    /**
33     * Transient prefix for a single product's full details.
34     */
35    const PRODUCT_CACHE_PREFIX = 'wpcom_marketplace_product_v' . self::CACHE_VERSION . '_';
36
37    /**
38     * How long a successful read is cached for.
39     */
40    const CACHE_TTL = 6 * HOUR_IN_SECONDS;
41
42    /**
43     * How long a failed read is cached for. Short, but non-zero, so an outage does
44     * not mean an outbound request per page load.
45     */
46    const MISS_CACHE_TTL = 5 * MINUTE_IN_SECONDS;
47
48    /**
49     * The largest yearly saving we will state as fact.
50     *
51     * Paying yearly saves a month or two at most in practice. Anything beyond this is
52     * a sign the two variations are not the same product on two billing terms, so the
53     * comparison is dropped rather than shown. See yearly_saving().
54     */
55    const MAX_PLAUSIBLE_SAVING = 50;
56
57    /**
58     * Block-level tags, used to work out which stripped tags owe a paragraph break.
59     */
60    private const MODAL_BLOCK_TAGS = array(
61        'address',
62        'article',
63        'aside',
64        'blockquote',
65        'dd',
66        'div',
67        'dl',
68        'dt',
69        'fieldset',
70        'figcaption',
71        'figure',
72        'footer',
73        'h1',
74        'h2',
75        'h3',
76        'h4',
77        'h5',
78        'h6',
79        'header',
80        'li',
81        'main',
82        'nav',
83        'ol',
84        'p',
85        'pre',
86        'section',
87        'table',
88        'tbody',
89        'td',
90        'tfoot',
91        'th',
92        'thead',
93        'tr',
94        'ul',
95    );
96
97    /**
98     * The markup a vendor description keeps once it reaches the details modal.
99     *
100     * Headings run to h6 because core's own `$plugins_allowedtags` does, so keeping
101     * them costs nothing downstream. Everything absent from here is stripped, and
102     * `to_modal_html()` derives from this which block tags owe a paragraph break.
103     */
104    private const MODAL_TAGS = array(
105        'a'          => array(
106            'href'  => array(),
107            'title' => array(),
108        ),
109        'blockquote' => array(),
110        'br'         => array(),
111        'code'       => array(),
112        'em'         => array(),
113        'h1'         => array(),
114        'h2'         => array(),
115        'h3'         => array(),
116        'h4'         => array(),
117        'h5'         => array(),
118        'h6'         => array(),
119        'li'         => array(),
120        'ol'         => array(),
121        'p'          => array(),
122        'strong'     => array(),
123        'ul'         => array(),
124    );
125
126    /**
127     * Every purchasable plugin, keyed by slug, in the order wpcom returns them.
128     *
129     * @return array<string, array> Normalized product data, empty when the catalog cannot be read.
130     */
131    public static function get_products() {
132        $cached = get_transient( self::LIST_CACHE_KEY );
133        if ( is_array( $cached ) ) {
134            return $cached;
135        }
136
137        $response = self::request( '/marketplace/products?type=launched' );
138
139        if ( ! is_array( $response ) || ! is_array( $response['results'] ?? null ) ) {
140            set_transient( self::LIST_CACHE_KEY, array(), self::MISS_CACHE_TTL );
141            return array();
142        }
143
144        $products = self::attach_pricing( self::to_catalog( $response['results'] ), self::fetch_store_products() );
145
146        set_transient( self::LIST_CACHE_KEY, $products, self::CACHE_TTL );
147
148        return $products;
149    }
150
151    /**
152     * Turns an endpoint response into the catalog we list.
153     *
154     * Order is load-bearing: wpcom ranks the response by active subscriptions, so
155     * the best sellers arrive first. Do not sort or re-key what comes back.
156     *
157     * @param array $results Products as the marketplace endpoint returns them.
158     * @return array<string, array> Normalized products, keyed by slug.
159     */
160    public static function to_catalog( array $results ) {
161        $products = array();
162
163        foreach ( $results as $product ) {
164            if ( ! is_array( $product ) || empty( $product['slug'] ) ) {
165                continue;
166            }
167
168            // Retired products stay available to existing subscribers but are no longer sold.
169            if ( ! empty( $product['is_retired'] ) || ! empty( $product['is_hidden'] ) ) {
170                continue;
171            }
172
173            $card = self::to_card( $product );
174
175            $products[ $card['slug'] ] = $card;
176        }
177
178        return $products;
179    }
180
181    /**
182     * One product's card data, as it appears in the browse list.
183     *
184     * @param string $slug Plugin slug.
185     * @return array|null Normalized product data, or null when the slug is not ours.
186     */
187    public static function get_product( $slug ) {
188        $products = self::get_products();
189
190        return $products[ $slug ] ?? null;
191    }
192
193    /**
194     * One product with the long-form fields the details modal renders.
195     *
196     * @param string $slug Plugin slug.
197     * @return array|null Normalized product data, or null when the slug is not ours.
198     */
199    public static function get_product_details( $slug ) {
200        $card = self::get_product( $slug );
201        if ( null === $card ) {
202            return null;
203        }
204
205        $cache_key = self::PRODUCT_CACHE_PREFIX . $slug;
206        $cached    = get_transient( $cache_key );
207        if ( is_array( $cached ) ) {
208            return $cached;
209        }
210
211        $product = self::request( '/marketplace/products/' . rawurlencode( $card['wpcom_product_slug'] ?? $slug ) );
212
213        // The card carries every field the modal needs except the long description.
214        if ( ! is_array( $product ) || empty( $product['slug'] ) ) {
215            $details = self::to_details( $card );
216
217            set_transient( $cache_key, $details, self::MISS_CACHE_TTL );
218
219            return $details;
220        }
221
222        $details = self::to_details( self::to_card( $product ) );
223
224        $raw = is_string( $product['description'] ?? null ) ? $product['description'] : '';
225
226        $description = self::to_modal_html( $raw );
227        if ( '' !== $description ) {
228            $details['sections']['description'] = $description;
229        }
230
231        $screenshots = self::to_screenshots_html( $raw );
232        if ( '' !== $screenshots ) {
233            $details['sections']['screenshots'] = $screenshots;
234        }
235
236        set_transient( $cache_key, $details, self::CACHE_TTL );
237
238        return $details;
239    }
240
241    /**
242     * Strips the fields that only exist to satisfy the browse list.
243     *
244     * The list table reads active_installs unguarded, so a card has to carry it, but
245     * the details modal guards on isset() and renders 0 as "Less Than 10", which we
246     * would be stating as fact about a plugin we have no install count for.
247     *
248     * @param array $card Normalized card data.
249     * @return array
250     */
251    private static function to_details( array $card ) {
252        unset( $card['active_installs'], $card['downloaded'] );
253
254        return $card;
255    }
256
257    /**
258     * Moves the vendor's images into a screenshots section.
259     *
260     * Core constrains images in `#section-screenshots` and nowhere else, which is why
261     * WordPress.org plugins put them there rather than in the description. Rebuilt
262     * from the source URLs alone so none of the vendor's own markup comes with them.
263     *
264     * @param string $html Description as the marketplace endpoint returns it.
265     * @return string Section markup, or an empty string when there are no images.
266     */
267    public static function to_screenshots_html( $html ) {
268        if ( '' === $html || ! preg_match_all( '#<img[^>]*?\s src=[\'"]([^\'"]+)[\'"]#ix', $html, $matches ) ) {
269            return '';
270        }
271
272        $items = '';
273        foreach ( array_unique( $matches[1] ) as $src ) {
274            $url = esc_url( $src );
275            if ( '' !== $url ) {
276                $items .= sprintf( '<li><img src="%s" alt="" /></li>', $url );
277            }
278        }
279
280        return '' === $items ? '' : '<ol>' . $items . '</ol>';
281    }
282
283    /**
284     * Reduces a vendor description to markup core's details modal can render.
285     *
286     * These are WooCommerce.com product pages: layout divs, full-width figures and
287     * inline styles, with the spacing living in a stylesheet the modal does not load.
288     * Left alone they overflow its ~600px column and the text runs together.
289     *
290     * @param string $html Description as the marketplace endpoint returns it.
291     * @return string
292     */
293    public static function to_modal_html( $html ) {
294        if ( '' === $html ) {
295            return '';
296        }
297
298        /*
299         * Core's own modal allowlist has no b or i, so that emphasis would be dropped
300         * one filter later. Normalized first so it survives, and so neither tag is
301         * still around when the block tags are counted below.
302         */
303        $html = preg_replace( '#<(/?)b\b([^>]*)>#i', '<$1strong$2>', $html );
304        $html = preg_replace( '#<(/?)i\b([^>]*)>#i', '<$1em$2>', $html );
305
306        /*
307         * A block tag that is about to be stripped keeps the break it implied, so the
308         * structure the vendor laid out survives. Taken as the block tags minus the
309         * ones we keep, which is what stops the two lists from disagreeing: before
310         * this, a `</td>` lost both its tag and its break and glued that cell onto the
311         * next one. Inline tags are deliberately not in here. The catalog carries 77
312         * `</span>` and breaking on those would split sentences down the middle.
313         */
314        $break = array_diff( self::MODAL_BLOCK_TAGS, array_keys( self::MODAL_TAGS ) );
315        $html  = preg_replace( '#</(?:' . implode( '|', $break ) . ')\s*>#i', "\n\n", $html );
316
317        $html = wp_kses( $html, self::MODAL_TAGS );
318
319        return trim( wpautop( trim( $html ) ) );
320    }
321
322    /**
323     * Reads a wpcom marketplace endpoint.
324     *
325     * @param string $path    Path below the namespace, query string included.
326     * @param string $version API version.
327     * @param string $base    API base, `wpcom` or `rest`.
328     * @return array|null Decoded response body, or null on any failure.
329     */
330    private static function request( $path, $version = '2', $base = 'wpcom' ) {
331        if ( ! method_exists( Client::class, 'wpcom_json_api_request_as_blog' ) ) {
332            return null;
333        }
334
335        $response = Client::wpcom_json_api_request_as_blog( $path, $version, array(), null, $base );
336
337        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
338            return null;
339        }
340
341        $body = json_decode( wp_remote_retrieve_body( $response ), true );
342
343        return is_array( $body ) ? $body : null;
344    }
345
346    /**
347     * Turns one wpcom product into the array a plugin card is rendered from.
348     *
349     * Core reads several of these keys without checking they exist, so every one it
350     * touches is set here even when we have nothing to put in it.
351     *
352     * @param array $product Product data from the marketplace endpoint.
353     * @return array
354     */
355    public static function to_card( array $product ) {
356        $product_slug = (string) ( $product['slug'] ?? '' );
357        $icon         = is_string( $product['icons'] ?? null ) ? $product['icons'] : '';
358
359        // Core resolves installed state from the plugin directory name, and
360        // Marketplace_Products_Updater keys its updates the same way, so the card has
361        // to carry the software slug. The two differ for a handful of products.
362        $slug = (string) ( $product['software_slug'] ?? '' );
363        if ( '' === $slug ) {
364            $slug = $product_slug;
365        }
366
367        return array(
368            'name'               => (string) ( $product['name'] ?? '' ),
369            'slug'               => $slug,
370            'version'            => (string) ( $product['version'] ?? '' ),
371            // wpcom wraps the author name in a placeholder link that goes nowhere.
372            'author'             => wp_strip_all_tags( (string) ( $product['author'] ?? '' ) ),
373            'author_profile'     => '',
374            'contributors'       => array(),
375            'short_description'  => (string) ( $product['short_description'] ?? '' ),
376            'sections'           => array( 'description' => (string) ( $product['short_description'] ?? '' ) ),
377            'icons'              => array(
378                '1x'      => $icon,
379                '2x'      => $icon,
380                'default' => $icon,
381            ),
382            'banners'            => is_array( $product['banners'] ?? null ) ? $product['banners'] : array(),
383            // The payload has an average but no count, and core renders stars from the
384            // average alone, so showing one would mean "(based on 0 ratings)".
385            'rating'             => 0,
386            'num_ratings'        => 0,
387            'ratings'            => array(),
388            'active_installs'    => 0,
389            'downloaded'         => 0,
390            'last_updated'       => (string) ( $product['last_updated'] ?? '' ),
391            'added'              => '',
392            'homepage'           => self::product_url( $product_slug ),
393            'donate_link'        => '',
394            // No download link: these install through a purchase, and its absence is also
395            // what keeps core from offering an Install button in the details modal.
396            'download_link'      => '',
397            'requires'           => false,
398            'requires_php'       => false,
399            'tested'             => '',
400            'upgrade_notice'     => '',
401            // Suppresses core's "WordPress.org Plugin Page" link. These are not on .org.
402            'external'           => true,
403            'wpcom_marketplace'  => true,
404            'wpcom_product_slug' => $product_slug,
405            'wpcom_category'     => self::to_category( $product['tags'] ?? null ),
406            // Where a referral product is actually bought. See is_referral().
407            'wpcom_referral_url' => is_string( $product['saas_landing_page'] ?? null ) ? $product['saas_landing_page'] : '',
408            'wpcom_variations'   => self::to_variation_ids( $product['variations'] ?? null ),
409            'wpcom_pricing'      => array(),
410            'wpcom_saving'       => 0,
411        );
412    }
413
414    /**
415     * Reads the store catalog, which is where a variation's slug and price live.
416     *
417     * The marketplace endpoint gives only a numeric product id, and checkout is
418     * addressed by slug, so this is needed for the button as much as for the price.
419     *
420     * @return array<int, array> Keyed by product id.
421     */
422    private static function fetch_store_products() {
423        // Site-scoped first, so prices come back in the site's own currency. Calypso
424        // reads the same two paths in the same order, for the same reason.
425        $blog_id  = self::blog_id();
426        $response = $blog_id > 0 ? self::request( '/sites/' . $blog_id . '/products', '1.1', 'rest' ) : null;
427
428        if ( ! is_array( $response ) ) {
429            $response = self::request( '/products', '1.1', 'rest' );
430        }
431
432        if ( ! is_array( $response ) ) {
433            return array();
434        }
435
436        $store = array();
437        foreach ( $response as $slug => $product ) {
438            if ( ! is_array( $product ) || empty( $product['product_id'] ) ) {
439                continue;
440            }
441
442            $store[ (int) $product['product_id'] ] = array(
443                'slug'  => (string) $slug,
444                'price' => (string) ( $product['cost_display'] ?? '' ),
445                'cost'  => isset( $product['cost'] ) ? (float) $product['cost'] : 0.0,
446                // Some of what the marketplace lists is not sold here at all. See is_referral().
447                'type'  => (string) ( $product['product_type'] ?? '' ),
448            );
449        }
450
451        return $store;
452    }
453
454    /**
455     * This site's WordPress.com blog id.
456     *
457     * `get_wpcom_blog_id()` only answers when IS_WPCOM or IS_ATOMIC is defined, which
458     * a test cannot set without it leaking into every other test in the process. The
459     * connection stores the same id, so falling back to it both covers a connected
460     * site the helper says nothing about and leaves the site-scoped read testable.
461     *
462     * @return int Blog id, or 0 when this site does not have one.
463     */
464    private static function blog_id() {
465        if ( function_exists( 'get_wpcom_blog_id' ) ) {
466            $blog_id = (int) get_wpcom_blog_id();
467
468            if ( $blog_id > 0 ) {
469                return $blog_id;
470            }
471        }
472
473        return class_exists( 'Jetpack_Options' ) ? (int) \Jetpack_Options::get_option( 'id' ) : 0;
474    }
475
476    /**
477     * Resolves each product's variations against the store catalog.
478     *
479     * @param array<string, array> $products Normalized products, keyed by slug.
480     * @param array<int, array>    $store    Store products, keyed by product id.
481     * @return array<string, array>
482     */
483    public static function attach_pricing( array $products, array $store ) {
484        foreach ( $products as $slug => $product ) {
485            $pricing = array();
486
487            foreach ( $product['wpcom_variations'] ?? array() as $term => $product_id ) {
488                if ( isset( $store[ $product_id ] ) ) {
489                    $pricing[ $term ] = $store[ $product_id ];
490                }
491            }
492
493            $products[ $slug ]['wpcom_pricing'] = $pricing;
494            $products[ $slug ]['wpcom_saving']  = self::yearly_saving( $pricing );
495        }
496
497        return $products;
498    }
499
500    /**
501     * Whether WordPress.com only refers this product rather than selling it.
502     *
503     * A handful of marketplace listings are SaaS: the subscription is bought from the
504     * vendor, and wpcom takes the referral. They still carry variations and prices,
505     * so nothing about the shape of the payload says not to sell them. What says so
506     * is `product_type`, which Calypso's own `isSaasProduct` reads for the same
507     * decision. Its `has_marketplace_product` counts `saas_plugin` as a marketplace
508     * product too, so asking whether something is from the marketplace does not
509     * answer this and never will.
510     *
511     * @param array $card Normalized product data.
512     * @return bool
513     */
514    public static function is_referral( array $card ) {
515        foreach ( $card['wpcom_pricing'] ?? array() as $variation ) {
516            if ( 'saas_plugin' === ( $variation['type'] ?? '' ) ) {
517                return true;
518            }
519        }
520
521        return false;
522    }
523
524    /**
525     * How much cheaper a year is than twelve months, as a whole percentage.
526     *
527     * Worth showing because it is not a flat discount: across the catalog it runs from
528     * nothing at all to a third off, so the number is the only honest way to say it.
529     *
530     * @param array $pricing Resolved pricing, keyed by term.
531     * @return int Percentage saved, or 0 when there is nothing to compare or nothing saved.
532     */
533    public static function yearly_saving( array $pricing ) {
534        $yearly  = (float) ( $pricing['yearly']['cost'] ?? 0 );
535        $monthly = (float) ( $pricing['monthly']['cost'] ?? 0 );
536
537        if ( $yearly <= 0 || $monthly <= 0 ) {
538            return 0;
539        }
540
541        $saving = (int) round( ( 1 - $yearly / ( $monthly * 12 ) ) * 100 );
542
543        /*
544         * Two variations of the same product are not always two ways to buy the same
545         * thing: a SaaS listing's year and month can be separate plans, so comparing
546         * them produces a number that means nothing. MailPoet's $312 year against its
547         * $140 month reads as 81% off, and Nelio's year costs more than twelve of its
548         * months. Clamping the negative one to zero hid that rather than catching it,
549         * so a saving outside what a billing term can plausibly be is refused.
550         */
551        if ( $saving <= 0 || $saving > self::MAX_PLAUSIBLE_SAVING ) {
552            return 0;
553        }
554
555        return $saving;
556    }
557
558    /**
559     * The product's category, as something short enough to sit on a card.
560     *
561     * Tags arrive as slug => label. Nearly every product carries "Plugins", which
562     * says nothing on a screen that only lists plugins, so the first tag after that
563     * is the one worth showing.
564     *
565     * @param mixed $tags Tags as the marketplace endpoint returns them.
566     * @return string Category label, or an empty string when there is nothing useful.
567     */
568    private static function to_category( $tags ) {
569        foreach ( is_array( $tags ) ? $tags : array() as $slug => $label ) {
570            if ( 'plugins' !== $slug && is_string( $label ) && '' !== trim( $label ) ) {
571                return trim( $label );
572            }
573        }
574
575        return '';
576    }
577
578    /**
579     * Flattens the endpoint's variations into term => product id.
580     *
581     * @param mixed $variations Variations as the marketplace endpoint returns them.
582     * @return array<string, int>
583     */
584    private static function to_variation_ids( $variations ) {
585        $ids = array();
586
587        foreach ( is_array( $variations ) ? $variations : array() as $term => $variation ) {
588            $product_id = is_array( $variation ) ? (int) ( $variation['product_id'] ?? 0 ) : 0;
589            if ( $product_id > 0 ) {
590                $ids[ (string) $term ] = $product_id;
591            }
592        }
593
594        return $ids;
595    }
596
597    /**
598     * The checkout URL for one variation, which both buys and activates the plugin.
599     *
600     * @param array  $card     Normalized product data.
601     * @param string $term     'yearly' or 'monthly'.
602     * @param string $back_url Where checkout's Back link should return to. Must be on
603     *                         this site's own host, or checkout ignores it.
604     * @return string Checkout URL, or an empty string when there is no such variation.
605     */
606    public static function checkout_url( array $card, $term, $back_url = '' ) {
607        $store_slug = $card['wpcom_pricing'][ $term ]['slug'] ?? '';
608        if ( '' === $store_slug ) {
609            return '';
610        }
611
612        $site_slug = wp_parse_url( home_url(), PHP_URL_HOST );
613
614        $url = sprintf(
615            'https://wordpress.com/checkout/%s/%s',
616            rawurlencode( (string) $site_slug ),
617            rawurlencode( $store_slug )
618        );
619
620        /*
621         * Without this, Back leaves for whichever Calypso page the reader came from,
622         * and for someone arriving straight from wp-admin that is the plan picker.
623         * Checkout allows a back URL on the site's own host, which is where we are.
624         * `add_query_arg()` does not encode values, so the URL is encoded here.
625         */
626        if ( '' !== $back_url ) {
627            $url = add_query_arg( 'checkoutBackUrl', rawurlencode( $back_url ), $url );
628        }
629
630        // Jumps past the plan step, which a marketplace purchase does not have.
631        return $url . '#step2';
632    }
633
634    /**
635     * The vendor URL a referral is bought from, naming the account and site referred.
636     *
637     * Vendors tie the order to a WordPress.com site through `uuid`, appended as Calypso's
638     * `getSaasRedirectUrl()` does. It names the viewer, so it is never cached with the card.
639     *
640     * @param array $card          Normalized product data.
641     * @param int   $wpcom_user_id The viewer's WordPress.com user id.
642     * @return string Referral URL, or an empty string when there is no landing page or no one to refer.
643     */
644    public static function referral_url( array $card, $wpcom_user_id ) {
645        $landing = (string) ( $card['wpcom_referral_url'] ?? '' );
646        $user_id = (int) $wpcom_user_id;
647        $blog_id = self::blog_id();
648
649        if ( '' === $landing || $user_id <= 0 || $blog_id <= 0 ) {
650            return '';
651        }
652
653        // Encoded here because add_query_arg() does not, and a bare + would arrive as a space.
654        return add_query_arg( 'uuid', rawurlencode( $user_id . '+' . $blog_id ), $landing );
655    }
656
657    /**
658     * The WordPress.com page a product is bought from.
659     *
660     * @param string $slug Plugin slug.
661     * @return string
662     */
663    public static function product_url( $slug ) {
664        $site_slug = wp_parse_url( home_url(), PHP_URL_HOST );
665
666        return sprintf(
667            'https://wordpress.com/plugins/%s/%s?ref=wpcom-marketplace-tab',
668            rawurlencode( $slug ),
669            rawurlencode( (string) $site_slug )
670        );
671    }
672}