Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.66% covered (warning)
72.66%
101 / 139
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Partner_Coupon
73.72% covered (warning)
73.72%
101 / 137
40.00% covered (danger)
40.00%
6 / 15
95.36
0.00% covered (danger)
0.00%
0 / 1
 get_instance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_coupon_admin_hooks
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 catch_coupon
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 maybe_purge_coupon
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
10.86
 maybe_purge_coupon_by_added_date
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 maybe_purge_coupon_by_availability_check
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
7.01
 delete_coupon_data
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_coupon
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 get_coupon_partner
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 get_coupon_product
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 array_keys_exist
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 get_coupon_preset
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 get_supported_partners
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_supported_presets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Class for the Jetpack partner coupon logic.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack;
9
10use Automattic\Jetpack\Connection\Client as Connection_Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Jetpack_Options;
13
14/**
15 * Disable direct access.
16 */
17if ( ! defined( 'ABSPATH' ) ) {
18    exit( 0 );
19}
20
21/**
22 * Class Jetpack_Partner_Coupon
23 *
24 * @since partner-1.6.0
25 * @since 2.0.0
26 */
27class Partner_Coupon {
28
29    /**
30     * Name of the Jetpack_Option coupon option.
31     *
32     * @var string
33     */
34    public static $coupon_option = 'partner_coupon';
35
36    /**
37     * Name of the Jetpack_Option added option.
38     *
39     * @var string
40     */
41    public static $added_option = 'partner_coupon_added';
42
43    /**
44     * Name of "last availability check" transient.
45     *
46     * @var string
47     */
48    public static $last_check_transient = 'jetpack_partner_coupon_last_check';
49
50    /**
51     * Callable that executes a blog-authenticated request.
52     *
53     * @var callable
54     */
55    protected $request_as_blog;
56
57    /**
58     * Jetpack_Partner_Coupon
59     *
60     * @var Partner_Coupon|null
61     **/
62    private static $instance = null;
63
64    /**
65     * A list of supported partners.
66     *
67     * @var array
68     */
69    private static $supported_partners = array(
70        'IONOS' => array(
71            'name' => 'IONOS',
72            'logo' => array(
73                'src'    => '/images/ionos-logo.jpg',
74                'width'  => 119,
75                'height' => 32,
76            ),
77        ),
78    );
79
80    /**
81     * A list of supported presets.
82     *
83     * @var array
84     */
85    private static $supported_presets = array(
86        'IONA' => 'jetpack_backup_daily',
87    );
88
89    /**
90     * Get singleton instance of class.
91     *
92     * @return Partner_Coupon
93     */
94    public static function get_instance() {
95        if ( self::$instance === null ) {
96            self::$instance = new Partner_Coupon( array( Connection_Client::class, 'wpcom_json_api_request_as_blog' ) );
97        }
98
99        return self::$instance;
100    }
101
102    /**
103     * Constructor.
104     *
105     * @param callable $request_as_blog Callable that executes a blog-authenticated request.
106     */
107    public function __construct( $request_as_blog ) {
108        $this->request_as_blog = $request_as_blog;
109    }
110
111    /**
112     * Register hooks to catch and purge coupon.
113     *
114     * @param string $plugin_slug The plugin slug to differentiate between Jetpack connections.
115     * @param string $redirect_location The location we should redirect to after catching the coupon.
116     */
117    public static function register_coupon_admin_hooks( $plugin_slug, $redirect_location ) {
118        $instance = self::get_instance();
119
120        // We have to use an anonymous function, so we can pass along relevant information
121        // and not have to hardcode values for a single plugin.
122        // This open up the opportunity for e.g. the "all-in-one" and backup plugins
123        // to both implement partner coupon logic.
124        add_action(
125            'admin_init',
126            function () use ( $plugin_slug, $redirect_location, $instance ) {
127                $instance->catch_coupon( $plugin_slug, $redirect_location );
128                $instance->maybe_purge_coupon( $plugin_slug );
129            }
130        );
131    }
132
133    /**
134     * Catch partner coupon and redirect to claim component.
135     *
136     * @param string $plugin_slug The plugin slug to differentiate between Jetpack connections.
137     * @param string $redirect_location The location we should redirect to after catching the coupon.
138     */
139    public function catch_coupon( $plugin_slug, $redirect_location ) {
140        // Accept and store a partner coupon if present, and redirect to Jetpack connection screen.
141        $partner_coupon = isset( $_GET['jetpack-partner-coupon'] ) ? sanitize_text_field( wp_unslash( $_GET['jetpack-partner-coupon'] ) ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
142        if ( $partner_coupon ) {
143            Jetpack_Options::update_options(
144                array(
145                    self::$coupon_option => $partner_coupon,
146                    self::$added_option  => time(),
147                )
148            );
149
150            $connection = new Connection_Manager( $plugin_slug );
151            if ( $connection->is_connected() ) {
152                $redirect_location = add_query_arg( array( 'showCouponRedemption' => 1 ), $redirect_location );
153                wp_safe_redirect( $redirect_location );
154            } else {
155                wp_safe_redirect( $redirect_location );
156            }
157        }
158    }
159
160    /**
161     * Purge partner coupon.
162     *
163     * We try to remotely check if a coupon looks valid. We also automatically purge
164     * partner coupons after a certain amount of time to prevent unnecessary look-ups
165     * and/or promoting a product for months or years in the future due to unknown
166     * errors.
167     *
168     * @param string $plugin_slug The plugin slug to differentiate between Jetpack connections.
169     */
170    public function maybe_purge_coupon( $plugin_slug ) {
171        // Only run coupon checks where the redemption screen can show.
172        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
173        $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
174        if ( ! in_array( $page, array( 'jetpack', 'my-jetpack' ), true ) ) {
175            return;
176        }
177
178        if ( ( new Status() )->is_offline_mode() ) {
179            return;
180        }
181
182        $connection = new Connection_Manager( $plugin_slug );
183        if ( ! $connection->is_connected() ) {
184            return;
185        }
186
187        if ( $this->maybe_purge_coupon_by_added_date() ) {
188            return;
189        }
190
191        // Limit checks to happen once a minute at most.
192        if ( get_transient( self::$last_check_transient ) ) {
193            return;
194        }
195
196        set_transient( self::$last_check_transient, true, MINUTE_IN_SECONDS );
197
198        $this->maybe_purge_coupon_by_availability_check();
199    }
200
201    /**
202     * Purge coupon based on local added date.
203     *
204     * We automatically remove the coupon after a month to "self-heal" if
205     * something in the claim process has broken with the site.
206     *
207     * @return bool Return whether we should skip further purge checks.
208     */
209    protected function maybe_purge_coupon_by_added_date() {
210        $date = Jetpack_Options::get_option( self::$added_option, '' );
211
212        if ( empty( $date ) ) {
213            return true;
214        }
215
216        $expire_date = strtotime( '+30 days', $date );
217        $today       = time();
218
219        if ( $today >= $expire_date ) {
220            $this->delete_coupon_data();
221
222            return true;
223        }
224
225        return false;
226    }
227
228    /**
229     * Purge coupon based on availability check.
230     *
231     * @return bool Return whether we deleted coupon data.
232     */
233    protected function maybe_purge_coupon_by_availability_check() {
234        $blog_id = Jetpack_Options::get_option( 'id', false );
235
236        if ( ! $blog_id ) {
237            return false;
238        }
239
240        $coupon = self::get_coupon();
241
242        if ( ! $coupon ) {
243            return false;
244        }
245
246        $response = call_user_func_array(
247            $this->request_as_blog,
248            array(
249                add_query_arg(
250                    array( 'coupon_code' => $coupon['coupon_code'] ),
251                    sprintf(
252                        '/sites/%d/jetpack-partner/coupon/v1/site/coupon',
253                        $blog_id
254                    )
255                ),
256                2,
257                array( 'method' => 'GET' ),
258                null,
259                'wpcom',
260            )
261        );
262
263        $body = json_decode( wp_remote_retrieve_body( $response ), true );
264
265        if (
266            200 === wp_remote_retrieve_response_code( $response ) &&
267            is_array( $body ) &&
268            isset( $body['available'] ) &&
269            false === $body['available']
270        ) {
271            $this->delete_coupon_data();
272
273            return true;
274        }
275
276        return false;
277    }
278
279    /**
280     * Delete all coupon data.
281     */
282    protected function delete_coupon_data() {
283        Jetpack_Options::delete_option(
284            array(
285                self::$coupon_option,
286                self::$added_option,
287            )
288        );
289    }
290
291    /**
292     * Get partner coupon data.
293     *
294     * @return array|bool
295     */
296    public static function get_coupon() {
297        $coupon_code = Jetpack_Options::get_option( self::$coupon_option, '' );
298
299        if ( ! is_string( $coupon_code ) || empty( $coupon_code ) ) {
300            return false;
301        }
302
303        $instance = self::get_instance();
304        $partner  = $instance->get_coupon_partner( $coupon_code );
305
306        if ( ! $partner ) {
307            return false;
308        }
309
310        $preset = $instance->get_coupon_preset( $coupon_code );
311
312        if ( ! $preset ) {
313            return false;
314        }
315
316        $product = $instance->get_coupon_product( $preset );
317
318        if ( ! $product ) {
319            return false;
320        }
321
322        return array(
323            'coupon_code' => $coupon_code,
324            'partner'     => $partner,
325            'preset'      => $preset,
326            'product'     => $product,
327        );
328    }
329
330    /**
331     * Get coupon partner.
332     *
333     * @param string $coupon_code Coupon code to go through.
334     * @return array|bool
335     */
336    private function get_coupon_partner( $coupon_code ) {
337        if ( ! is_string( $coupon_code ) || false === strpos( $coupon_code, '_' ) ) {
338            return false;
339        }
340
341        $prefix             = strtok( $coupon_code, '_' );
342        $supported_partners = $this->get_supported_partners();
343
344        if ( ! isset( $supported_partners[ $prefix ] ) ) {
345            return false;
346        }
347
348        return array(
349            'name'   => $supported_partners[ $prefix ]['name'],
350            'prefix' => $prefix,
351            'logo'   => $supported_partners[ $prefix ]['logo'] ?? null,
352        );
353    }
354
355    /**
356     * Get coupon product.
357     *
358     * @param string $coupon_preset The preset we wish to find a product for.
359     * @return array|bool
360     */
361    private function get_coupon_product( $coupon_preset ) {
362        if ( ! is_string( $coupon_preset ) ) {
363            return false;
364        }
365
366        /**
367         * Allow for plugins to register supported products.
368         *
369         * @since 1.6.0
370         *
371         * @param array A list of product details.
372         * @return array
373         */
374        $product_details = apply_filters( 'jetpack_partner_coupon_products', array() );
375        $product_slug    = $this->get_supported_presets()[ $coupon_preset ];
376
377        foreach ( $product_details as $product ) {
378            if ( ! $this->array_keys_exist( array( 'title', 'slug', 'description', 'features' ), $product ) ) {
379                continue;
380            }
381
382            if ( $product_slug === $product['slug'] ) {
383                return $product;
384            }
385        }
386
387        return false;
388    }
389
390    /**
391     * Checks if multiple keys are present in an array.
392     *
393     * @param array $needles The keys we wish to check for.
394     * @param array $haystack The array we want to compare keys against.
395     *
396     * @return bool
397     */
398    private function array_keys_exist( $needles, $haystack ) {
399        foreach ( $needles as $needle ) {
400            if ( ! isset( $haystack[ $needle ] ) ) {
401                return false;
402            }
403        }
404
405        return true;
406    }
407
408    /**
409     * Get coupon preset.
410     *
411     * @param string $coupon_code Coupon code to go through.
412     * @return string|bool
413     */
414    private function get_coupon_preset( $coupon_code ) {
415        if ( ! is_string( $coupon_code ) ) {
416            return false;
417        }
418
419        $regex   = '/^.*?_(?P<slug>.*?)_.+$/';
420        $matches = array();
421
422        if ( ! preg_match( $regex, $coupon_code, $matches ) ) {
423            return false;
424        }
425
426        return isset( $this->get_supported_presets()[ $matches['slug'] ] ) ? $matches['slug'] : false;
427    }
428
429    /**
430     * Get supported partners.
431     *
432     * @return array
433     */
434    private function get_supported_partners() {
435        /**
436         * Allow external code to add additional supported partners.
437         *
438         * @since partner-1.6.0
439         * @since 2.0.0
440         *
441         * @param array $supported_partners A list of supported partners.
442         * @return array
443         */
444        return apply_filters( 'jetpack_partner_coupon_supported_partners', self::$supported_partners );
445    }
446
447    /**
448     * Get supported presets.
449     *
450     * @return array
451     */
452    private function get_supported_presets() {
453        /**
454         * Allow external code to add additional supported presets.
455         *
456         * @since partner-1.6.0
457         * @since 2.0.0
458         *
459         * @param array $supported_presets A list of supported presets.
460         * @return array
461         */
462        return apply_filters( 'jetpack_partner_coupon_supported_presets', self::$supported_presets );
463    }
464}