Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.71% covered (warning)
85.71%
30 / 35
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCom_Themes_Store
85.71% covered (warning)
85.71%
30 / 35
60.00% covered (warning)
60.00%
3 / 5
21.17
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 products
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 subscriptions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 fetch_products
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
6.10
 request
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
8.32
1<?php
2/**
3 * Class WPCom_Themes_Store.
4 * How a theme is sold, and whether it is already paid for.
5 *
6 * @package automattic/jetpack-mu-wpcom
7 */
8
9use Automattic\Jetpack\Connection\Client;
10
11/**
12 * Reads the WordPress.com store for the themes a plan does not grant.
13 *
14 * Only the partner tier is sold this way: every other tier comes with a plan, which
15 * WPCom_Themes_Tiers answers for. Prices are left out: the store quotes a request in the
16 * currency of wherever it came from, so only the browser can ask for the reader's own.
17 */
18class WPCom_Themes_Store {
19
20    /**
21     * The billing term a theme is offered by here.
22     *
23     * Yearly, as the plugins Marketplace tab offers plugins: the cheaper of the two terms,
24     * and one price rather than a choice to make on a card.
25     */
26    const TERM = 'year';
27
28    /**
29     * The store's name for a theme sold on its own.
30     */
31    const PRODUCT_TYPE = 'marketplace_theme';
32
33    /**
34     * The store's catalog, which the browser reads again to price a theme for its reader.
35     */
36    const PRODUCTS_URL = 'https://public-api.wordpress.com/rest/v1.1/products';
37
38    /**
39     * Cache handler.
40     *
41     * @var WPCom_Themes_Cache $cache
42     */
43    private WPCom_Themes_Cache $cache;
44
45    /**
46     * Class constructor.
47     *
48     * @param WPCom_Themes_Cache $cache Cache handler.
49     */
50    public function __construct( WPCom_Themes_Cache $cache ) {
51        $this->cache = $cache;
52    }
53
54    /**
55     * Every theme the store sells, by product slug.
56     *
57     * @return array<string, array{term: string, billing_slug: string}>
58     */
59    public function products(): array {
60        return $this->cache->run_cached(
61            'wpcom-themes-store-products',
62            fn() => $this->fetch_products()
63        ) ?? array();
64    }
65
66    /**
67     * The billing products this site already subscribes to.
68     *
69     * Billing slugs rather than product slugs, so a theme bought by the month is still
70     * recognised as bought. Calypso's isMarketplaceThemeSubscribed matches the same way.
71     *
72     * @return array<string> Billing product slugs.
73     */
74    public function subscriptions(): array {
75        $slugs = array();
76
77        foreach ( (array) wpcom_themes_get_purchases() as $purchase ) {
78            $slug = is_object( $purchase ) ? ( $purchase->billing_product_slug ?? '' ) : '';
79
80            if ( is_string( $slug ) && '' !== $slug ) {
81                $slugs[] = $slug;
82            }
83        }
84
85        return $slugs;
86    }
87
88    /**
89     * Reads the store catalog, keeping the themes in it.
90     *
91     * @return array<string, array>|null Null when the store could not be read, which is not cached.
92     */
93    private function fetch_products(): ?array {
94        $response = $this->request( '/products' );
95
96        if ( null === $response ) {
97            return null;
98        }
99
100        $products = array();
101        foreach ( $response as $slug => $product ) {
102            if ( ! is_array( $product ) || self::PRODUCT_TYPE !== ( $product['product_type'] ?? '' ) ) {
103                continue;
104            }
105
106            // A product the store has withdrawn cannot be bought, so it is not offered.
107            if ( false === ( $product['available'] ?? true ) ) {
108                continue;
109            }
110
111            $products[ (string) $slug ] = array(
112                'term'         => (string) ( $product['product_term'] ?? '' ),
113                'billing_slug' => (string) ( $product['billing_product_slug'] ?? '' ),
114            );
115        }
116
117        return $products;
118    }
119
120    /**
121     * Asks WordPress.com as whoever is reading, which is who checkout would charge.
122     *
123     * @param string $path Store API path.
124     * @return array|null Decoded body, or null when the request failed.
125     */
126    private function request( string $path ): ?array {
127        // wpcom_json_api_request_as_user does not support internal requests.
128        $as = defined( 'IS_WPCOM' ) && IS_WPCOM
129            ? 'wpcom_json_api_request_as_blog'
130            : 'wpcom_json_api_request_as_user';
131
132        if ( ! method_exists( Client::class, $as ) ) {
133            return null;
134        }
135
136        $response = Client::$as( $path, '1.1', array(), null, 'rest' );
137
138        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
139            return null;
140        }
141
142        $body = json_decode( wp_remote_retrieve_body( $response ), true );
143
144        return is_array( $body ) ? $body : null;
145    }
146}