Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.39% covered (warning)
67.39%
93 / 138
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Partner_Coupon
68.38% covered (warning)
68.38%
93 / 136
40.00% covered (danger)
40.00%
6 / 15
133.21
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
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 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
5.02
 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 on Jetpack admin pages.
172        // The "admin-ui" package is responsible for registering the Jetpack admin
173        // page for all Jetpack plugins and has hardcoded the settings page to be
174        // "jetpack", so we shouldn't need to allow for dynamic/custom values.
175        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
176        if ( ! isset( $_GET['page'] ) || 'jetpack' !== $_GET['page'] ) {
177            return;
178        }
179
180        if ( ( new Status() )->is_offline_mode() ) {
181            return;
182        }
183
184        $connection = new Connection_Manager( $plugin_slug );
185        if ( ! $connection->is_connected() ) {
186            return;
187        }
188
189        if ( $this->maybe_purge_coupon_by_added_date() ) {
190            return;
191        }
192
193        // Limit checks to happen once a minute at most.
194        if ( get_transient( self::$last_check_transient ) ) {
195            return;
196        }
197
198        set_transient( self::$last_check_transient, true, MINUTE_IN_SECONDS );
199
200        $this->maybe_purge_coupon_by_availability_check();
201    }
202
203    /**
204     * Purge coupon based on local added date.
205     *
206     * We automatically remove the coupon after a month to "self-heal" if
207     * something in the claim process has broken with the site.
208     *
209     * @return bool Return whether we should skip further purge checks.
210     */
211    protected function maybe_purge_coupon_by_added_date() {
212        $date = Jetpack_Options::get_option( self::$added_option, '' );
213
214        if ( empty( $date ) ) {
215            return true;
216        }
217
218        $expire_date = strtotime( '+30 days', $date );
219        $today       = time();
220
221        if ( $today >= $expire_date ) {
222            $this->delete_coupon_data();
223
224            return true;
225        }
226
227        return false;
228    }
229
230    /**
231     * Purge coupon based on availability check.
232     *
233     * @return bool Return whether we deleted coupon data.
234     */
235    protected function maybe_purge_coupon_by_availability_check() {
236        $blog_id = Jetpack_Options::get_option( 'id', false );
237
238        if ( ! $blog_id ) {
239            return false;
240        }
241
242        $coupon = self::get_coupon();
243
244        if ( ! $coupon ) {
245            return false;
246        }
247
248        $response = call_user_func_array(
249            $this->request_as_blog,
250            array(
251                add_query_arg(
252                    array( 'coupon_code' => $coupon['coupon_code'] ),
253                    sprintf(
254                        '/sites/%d/jetpack-partner/coupon/v1/site/coupon',
255                        $blog_id
256                    )
257                ),
258                2,
259                array( 'method' => 'GET' ),
260                null,
261                'wpcom',
262            )
263        );
264
265        $body = json_decode( wp_remote_retrieve_body( $response ), true );
266
267        if (
268            200 === wp_remote_retrieve_response_code( $response ) &&
269            is_array( $body ) &&
270            isset( $body['available'] ) &&
271            false === $body['available']
272        ) {
273            $this->delete_coupon_data();
274
275            return true;
276        }
277
278        return false;
279    }
280
281    /**
282     * Delete all coupon data.
283     */
284    protected function delete_coupon_data() {
285        Jetpack_Options::delete_option(
286            array(
287                self::$coupon_option,
288                self::$added_option,
289            )
290        );
291    }
292
293    /**
294     * Get partner coupon data.
295     *
296     * @return array|bool
297     */
298    public static function get_coupon() {
299        $coupon_code = Jetpack_Options::get_option( self::$coupon_option, '' );
300
301        if ( ! is_string( $coupon_code ) || empty( $coupon_code ) ) {
302            return false;
303        }
304
305        $instance = self::get_instance();
306        $partner  = $instance->get_coupon_partner( $coupon_code );
307
308        if ( ! $partner ) {
309            return false;
310        }
311
312        $preset = $instance->get_coupon_preset( $coupon_code );
313
314        if ( ! $preset ) {
315            return false;
316        }
317
318        $product = $instance->get_coupon_product( $preset );
319
320        if ( ! $product ) {
321            return false;
322        }
323
324        return array(
325            'coupon_code' => $coupon_code,
326            'partner'     => $partner,
327            'preset'      => $preset,
328            'product'     => $product,
329        );
330    }
331
332    /**
333     * Get coupon partner.
334     *
335     * @param string $coupon_code Coupon code to go through.
336     * @return array|bool
337     */
338    private function get_coupon_partner( $coupon_code ) {
339        if ( ! is_string( $coupon_code ) || false === strpos( $coupon_code, '_' ) ) {
340            return false;
341        }
342
343        $prefix             = strtok( $coupon_code, '_' );
344        $supported_partners = $this->get_supported_partners();
345
346        if ( ! isset( $supported_partners[ $prefix ] ) ) {
347            return false;
348        }
349
350        return array(
351            'name'   => $supported_partners[ $prefix ]['name'],
352            'prefix' => $prefix,
353            'logo'   => isset( $supported_partners[ $prefix ]['logo'] ) ? $supported_partners[ $prefix ]['logo'] : null,
354        );
355    }
356
357    /**
358     * Get coupon product.
359     *
360     * @param string $coupon_preset The preset we wish to find a product for.
361     * @return array|bool
362     */
363    private function get_coupon_product( $coupon_preset ) {
364        if ( ! is_string( $coupon_preset ) ) {
365            return false;
366        }
367
368        /**
369         * Allow for plugins to register supported products.
370         *
371         * @since 1.6.0
372         *
373         * @param array A list of product details.
374         * @return array
375         */
376        $product_details = apply_filters( 'jetpack_partner_coupon_products', array() );
377        $product_slug    = $this->get_supported_presets()[ $coupon_preset ];
378
379        foreach ( $product_details as $product ) {
380            if ( ! $this->array_keys_exist( array( 'title', 'slug', 'description', 'features' ), $product ) ) {
381                continue;
382            }
383
384            if ( $product_slug === $product['slug'] ) {
385                return $product;
386            }
387        }
388
389        return false;
390    }
391
392    /**
393     * Checks if multiple keys are present in an array.
394     *
395     * @param array $needles The keys we wish to check for.
396     * @param array $haystack The array we want to compare keys against.
397     *
398     * @return bool
399     */
400    private function array_keys_exist( $needles, $haystack ) {
401        foreach ( $needles as $needle ) {
402            if ( ! isset( $haystack[ $needle ] ) ) {
403                return false;
404            }
405        }
406
407        return true;
408    }
409
410    /**
411     * Get coupon preset.
412     *
413     * @param string $coupon_code Coupon code to go through.
414     * @return string|bool
415     */
416    private function get_coupon_preset( $coupon_code ) {
417        if ( ! is_string( $coupon_code ) ) {
418            return false;
419        }
420
421        $regex   = '/^.*?_(?P<slug>.*?)_.+$/';
422        $matches = array();
423
424        if ( ! preg_match( $regex, $coupon_code, $matches ) ) {
425            return false;
426        }
427
428        return isset( $this->get_supported_presets()[ $matches['slug'] ] ) ? $matches['slug'] : false;
429    }
430
431    /**
432     * Get supported partners.
433     *
434     * @return array
435     */
436    private function get_supported_partners() {
437        /**
438         * Allow external code to add additional supported partners.
439         *
440         * @since partner-1.6.0
441         * @since 2.0.0
442         *
443         * @param array $supported_partners A list of supported partners.
444         * @return array
445         */
446        return apply_filters( 'jetpack_partner_coupon_supported_partners', self::$supported_partners );
447    }
448
449    /**
450     * Get supported presets.
451     *
452     * @return array
453     */
454    private function get_supported_presets() {
455        /**
456         * Allow external code to add additional supported presets.
457         *
458         * @since partner-1.6.0
459         * @since 2.0.0
460         *
461         * @param array $supported_presets A list of supported presets.
462         * @return array
463         */
464        return apply_filters( 'jetpack_partner_coupon_supported_presets', self::$supported_presets );
465    }
466}