Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.49% covered (success)
98.49%
196 / 199
85.71% covered (warning)
85.71%
18 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Marketplace_Catalog
98.49% covered (success)
98.49%
196 / 199
85.71% covered (warning)
85.71%
18 / 21
91
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%
44 / 44
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_requires_plugins
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 get_dependency_slugs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 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 = 9;
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            // Core names these in the card's dependency notice. WordPress.com installs them on purchase.
400            'requires_plugins'   => self::to_requires_plugins( $product['requirements'] ?? null, array( $slug, $product_slug ) ),
401            'tested'             => '',
402            'upgrade_notice'     => '',
403            // Suppresses core's "WordPress.org Plugin Page" link. These are not on .org.
404            'external'           => true,
405            'wpcom_marketplace'  => true,
406            'wpcom_product_slug' => $product_slug,
407            'wpcom_category'     => self::to_category( $product['tags'] ?? null ),
408            // Where a referral product is actually bought. See is_referral().
409            'wpcom_referral_url' => is_string( $product['saas_landing_page'] ?? null ) ? $product['saas_landing_page'] : '',
410            'wpcom_variations'   => self::to_variation_ids( $product['variations'] ?? null ),
411            'wpcom_pricing'      => array(),
412            'wpcom_saving'       => 0,
413        );
414    }
415
416    /**
417     * Reads the store catalog, which is where a variation's slug and price live.
418     *
419     * The marketplace endpoint gives only a numeric product id, and checkout is
420     * addressed by slug, so this is needed for the button as much as for the price.
421     *
422     * @return array<int, array> Keyed by product id.
423     */
424    private static function fetch_store_products() {
425        // Site-scoped first, so prices come back in the site's own currency. Calypso
426        // reads the same two paths in the same order, for the same reason.
427        $blog_id  = self::blog_id();
428        $response = $blog_id > 0 ? self::request( '/sites/' . $blog_id . '/products', '1.1', 'rest' ) : null;
429
430        if ( ! is_array( $response ) ) {
431            $response = self::request( '/products', '1.1', 'rest' );
432        }
433
434        if ( ! is_array( $response ) ) {
435            return array();
436        }
437
438        $store = array();
439        foreach ( $response as $slug => $product ) {
440            if ( ! is_array( $product ) || empty( $product['product_id'] ) ) {
441                continue;
442            }
443
444            $store[ (int) $product['product_id'] ] = array(
445                'slug'  => (string) $slug,
446                'price' => (string) ( $product['cost_display'] ?? '' ),
447                'cost'  => isset( $product['cost'] ) ? (float) $product['cost'] : 0.0,
448                // Some of what the marketplace lists is not sold here at all. See is_referral().
449                'type'  => (string) ( $product['product_type'] ?? '' ),
450            );
451        }
452
453        return $store;
454    }
455
456    /**
457     * This site's WordPress.com blog id.
458     *
459     * `get_wpcom_blog_id()` only answers when IS_WPCOM or IS_ATOMIC is defined, which
460     * a test cannot set without it leaking into every other test in the process. The
461     * connection stores the same id, so falling back to it both covers a connected
462     * site the helper says nothing about and leaves the site-scoped read testable.
463     *
464     * @return int Blog id, or 0 when this site does not have one.
465     */
466    private static function blog_id() {
467        if ( function_exists( 'get_wpcom_blog_id' ) ) {
468            $blog_id = (int) get_wpcom_blog_id();
469
470            if ( $blog_id > 0 ) {
471                return $blog_id;
472            }
473        }
474
475        return class_exists( 'Jetpack_Options' ) ? (int) \Jetpack_Options::get_option( 'id' ) : 0;
476    }
477
478    /**
479     * Resolves each product's variations against the store catalog.
480     *
481     * @param array<string, array> $products Normalized products, keyed by slug.
482     * @param array<int, array>    $store    Store products, keyed by product id.
483     * @return array<string, array>
484     */
485    public static function attach_pricing( array $products, array $store ) {
486        foreach ( $products as $slug => $product ) {
487            $pricing = array();
488
489            foreach ( $product['wpcom_variations'] ?? array() as $term => $product_id ) {
490                if ( isset( $store[ $product_id ] ) ) {
491                    $pricing[ $term ] = $store[ $product_id ];
492                }
493            }
494
495            $products[ $slug ]['wpcom_pricing'] = $pricing;
496            $products[ $slug ]['wpcom_saving']  = self::yearly_saving( $pricing );
497        }
498
499        return $products;
500    }
501
502    /**
503     * Whether WordPress.com only refers this product rather than selling it.
504     *
505     * A handful of marketplace listings are SaaS: the subscription is bought from the
506     * vendor, and wpcom takes the referral. They still carry variations and prices,
507     * so nothing about the shape of the payload says not to sell them. What says so
508     * is `product_type`, which Calypso's own `isSaasProduct` reads for the same
509     * decision. Its `has_marketplace_product` counts `saas_plugin` as a marketplace
510     * product too, so asking whether something is from the marketplace does not
511     * answer this and never will.
512     *
513     * @param array $card Normalized product data.
514     * @return bool
515     */
516    public static function is_referral( array $card ) {
517        foreach ( $card['wpcom_pricing'] ?? array() as $variation ) {
518            if ( 'saas_plugin' === ( $variation['type'] ?? '' ) ) {
519                return true;
520            }
521        }
522
523        return false;
524    }
525
526    /**
527     * How much cheaper a year is than twelve months, as a whole percentage.
528     *
529     * Worth showing because it is not a flat discount: across the catalog it runs from
530     * nothing at all to a third off, so the number is the only honest way to say it.
531     *
532     * @param array $pricing Resolved pricing, keyed by term.
533     * @return int Percentage saved, or 0 when there is nothing to compare or nothing saved.
534     */
535    public static function yearly_saving( array $pricing ) {
536        $yearly  = (float) ( $pricing['yearly']['cost'] ?? 0 );
537        $monthly = (float) ( $pricing['monthly']['cost'] ?? 0 );
538
539        if ( $yearly <= 0 || $monthly <= 0 ) {
540            return 0;
541        }
542
543        $saving = (int) round( ( 1 - $yearly / ( $monthly * 12 ) ) * 100 );
544
545        /*
546         * Two variations of the same product are not always two ways to buy the same
547         * thing: a SaaS listing's year and month can be separate plans, so comparing
548         * them produces a number that means nothing. MailPoet's $312 year against its
549         * $140 month reads as 81% off, and Nelio's year costs more than twelve of its
550         * months. Clamping the negative one to zero hid that rather than catching it,
551         * so a saving outside what a billing term can plausibly be is refused.
552         */
553        if ( $saving <= 0 || $saving > self::MAX_PLAUSIBLE_SAVING ) {
554            return 0;
555        }
556
557        return $saving;
558    }
559
560    /**
561     * The product's category, as something short enough to sit on a card.
562     *
563     * Tags arrive as slug => label. Nearly every product carries "Plugins", which
564     * says nothing on a screen that only lists plugins, so the first tag after that
565     * is the one worth showing.
566     *
567     * @param mixed $tags Tags as the marketplace endpoint returns them.
568     * @return string Category label, or an empty string when there is nothing useful.
569     */
570    private static function to_category( $tags ) {
571        foreach ( is_array( $tags ) ? $tags : array() as $slug => $label ) {
572            if ( 'plugins' !== $slug && is_string( $label ) && '' !== trim( $label ) ) {
573                return trim( $label );
574            }
575        }
576
577        return '';
578    }
579
580    /**
581     * The plugins a product needs, as the slugs core's dependency notice reads.
582     *
583     * @param mixed    $requirements Requirements as the marketplace endpoint returns them.
584     * @param string[] $own          The product's own slugs, which some products list as a requirement.
585     * @return string[]
586     */
587    private static function to_requires_plugins( $requirements, array $own ) {
588        $plugins = is_array( $requirements ) && is_array( $requirements['plugins'] ?? null ) ? $requirements['plugins'] : array();
589        $slugs   = array();
590
591        foreach ( $plugins as $plugin ) {
592            if ( is_string( $plugin ) && '' !== $plugin && ! in_array( $plugin, $own, true ) ) {
593                $slugs[] = $plugin;
594            }
595        }
596
597        return array_values( array_unique( $slugs ) );
598    }
599
600    /**
601     * Every plugin some product in the catalog needs.
602     *
603     * @return string[]
604     */
605    public static function get_dependency_slugs() {
606        $slugs = array();
607        foreach ( self::get_products() as $card ) {
608            foreach ( $card['requires_plugins'] ?? array() as $slug ) {
609                $slugs[ $slug ] = true;
610            }
611        }
612
613        return array_keys( $slugs );
614    }
615
616    /**
617     * Flattens the endpoint's variations into term => product id.
618     *
619     * @param mixed $variations Variations as the marketplace endpoint returns them.
620     * @return array<string, int>
621     */
622    private static function to_variation_ids( $variations ) {
623        $ids = array();
624
625        foreach ( is_array( $variations ) ? $variations : array() as $term => $variation ) {
626            $product_id = is_array( $variation ) ? (int) ( $variation['product_id'] ?? 0 ) : 0;
627            if ( $product_id > 0 ) {
628                $ids[ (string) $term ] = $product_id;
629            }
630        }
631
632        return $ids;
633    }
634
635    /**
636     * The checkout URL for one variation, which both buys and activates the plugin.
637     *
638     * @param array  $card     Normalized product data.
639     * @param string $term     'yearly' or 'monthly'.
640     * @param string $back_url Where checkout's Back link should return to. Must be on
641     *                         this site's own host, or checkout ignores it.
642     * @return string Checkout URL, or an empty string when there is no such variation.
643     */
644    public static function checkout_url( array $card, $term, $back_url = '' ) {
645        $store_slug = $card['wpcom_pricing'][ $term ]['slug'] ?? '';
646        if ( '' === $store_slug ) {
647            return '';
648        }
649
650        $site_slug = wp_parse_url( home_url(), PHP_URL_HOST );
651
652        $url = sprintf(
653            'https://wordpress.com/checkout/%s/%s',
654            rawurlencode( (string) $site_slug ),
655            rawurlencode( $store_slug )
656        );
657
658        /*
659         * Without this, Back leaves for whichever Calypso page the reader came from,
660         * and for someone arriving straight from wp-admin that is the plan picker.
661         * Checkout allows a back URL on the site's own host, which is where we are.
662         * `add_query_arg()` does not encode values, so the URL is encoded here.
663         */
664        if ( '' !== $back_url ) {
665            $url = add_query_arg( 'checkoutBackUrl', rawurlencode( $back_url ), $url );
666        }
667
668        // Jumps past the plan step, which a marketplace purchase does not have.
669        return $url . '#step2';
670    }
671
672    /**
673     * The vendor URL a referral is bought from, naming the account and site referred.
674     *
675     * Vendors tie the order to a WordPress.com site through `uuid`, appended as Calypso's
676     * `getSaasRedirectUrl()` does. It names the viewer, so it is never cached with the card.
677     *
678     * @param array $card          Normalized product data.
679     * @param int   $wpcom_user_id The viewer's WordPress.com user id.
680     * @return string Referral URL, or an empty string when there is no landing page or no one to refer.
681     */
682    public static function referral_url( array $card, $wpcom_user_id ) {
683        $landing = (string) ( $card['wpcom_referral_url'] ?? '' );
684        $user_id = (int) $wpcom_user_id;
685        $blog_id = self::blog_id();
686
687        if ( '' === $landing || $user_id <= 0 || $blog_id <= 0 ) {
688            return '';
689        }
690
691        // Encoded here because add_query_arg() does not, and a bare + would arrive as a space.
692        return add_query_arg( 'uuid', rawurlencode( $user_id . '+' . $blog_id ), $landing );
693    }
694
695    /**
696     * The WordPress.com page a product is bought from.
697     *
698     * @param string $slug Plugin slug.
699     * @return string
700     */
701    public static function product_url( $slug ) {
702        $site_slug = wp_parse_url( home_url(), PHP_URL_HOST );
703
704        return sprintf(
705            'https://wordpress.com/plugins/%s/%s?ref=wpcom-marketplace-tab',
706            rawurlencode( $slug ),
707            rawurlencode( (string) $site_slug )
708        );
709    }
710}