PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / trunk
Subscriptions for WooCommerce with Stripe Recurring Payments vtrunk
2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / includes / Admin / CancellationFlow.php

CancellationFlow.php in Subscriptions for WooCommerce with Stripe Recurring Payments trunk, at includes/Admin/CancellationFlow.php

451 lines 13.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cancellation Flow - admin page.
4 *
5 * The home of everything that happens when a customer cancels: the reason list
6 * customers pick from, and the flow-wide options beside it.
7 *
8 * These settings used to live on Settings -> Customers -> Cancellation. The
9 * move keeps every option key byte-identical, so nothing that reads them had to
10 * change - see Illuminate\Cancellation::get_settings() and ::get_reasons(),
11 * which remain the only readers.
12 *
13 * What did have to change is the settings GROUP. wp-admin/options.php writes
14 * `null` to every option registered in the submitted group that is absent from
15 * the POST, so leaving these four in `wp_subscription_settings` would mean
16 * saving the Settings page wiped them - and saving this page wiped everything
17 * else. They are registered in their own group instead (see OPTION_GROUP), and
18 * Pro registers its options into the same group.
19 *
20 * @package SpringDevs\Subscription\Admin
21 */
22
23 namespace SpringDevs\Subscription\Admin;
24
25 use SpringDevs\Subscription\Illuminate\Cancellation;
26
27 /**
28 * Cancellation Flow - admin page.
29 */
30 class CancellationFlow {
31
32 /**
33 * Admin page slug.
34 */
35 const SLUG = 'wp-subscription-cancellation';
36
37 /**
38 * Settings group for every option on this page.
39 *
40 * Deliberately separate from `wp_subscription_settings`: options.php nulls
41 * anything registered in a group but missing from the posted form, so two
42 * pages must never share one group.
43 */
44 const OPTION_GROUP = 'wp_subscription_cancellation_settings';
45
46 /**
47 * The page hook returned by add_submenu_page (for the enqueue gate).
48 *
49 * @var string
50 */
51 private $hook = '';
52
53 /**
54 * Initialize the class.
55 */
56 public function __construct() {
57 add_action( 'admin_menu', array( $this, 'register_page' ), 11 );
58 add_filter( 'subscrpt_submenu_order', array( $this, 'position_submenu' ) );
59 add_action( 'admin_init', array( $this, 'register_settings' ) );
60 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
61 }
62
63 /**
64 * Register the Cancellation Flow submenu under the WPSubscription top menu.
65 *
66 * @return void
67 */
68 public function register_page() {
69 $this->hook = (string) add_submenu_page(
70 'wp-subscription',
71 __( 'Cancellation Flow', 'subscription' ),
72 __( 'Cancellation Flow', 'subscription' ),
73 'manage_options',
74 self::SLUG,
75 array( $this, 'render_page' )
76 );
77 }
78
79 /**
80 * Position the item within the WPSubscription submenu order.
81 *
82 * @param array $order Ordered slug => position map.
83 * @return array
84 */
85 public function position_submenu( $order ) {
86 if ( is_array( $order ) ) {
87 $order[ self::SLUG ] = 70;
88 }
89 return $order;
90 }
91
92 /**
93 * Register the free options this page owns.
94 *
95 * Same option keys as before the move, new group. Pro registers
96 * `subscrpt_cancellation_delay` into this same group from its own
97 * Cancellation class. The reason list used to be Pro's too; it is free's now,
98 * and Pro only registers it against a free version without sanitize_reasons().
99 *
100 * @return void
101 */
102 public function register_settings() {
103 register_setting(
104 self::OPTION_GROUP,
105 'subscrpt_cancellation_feedback_enabled',
106 array(
107 'type' => 'string',
108 'default' => '1',
109 'sanitize_callback' => 'sanitize_text_field',
110 )
111 );
112 register_setting(
113 self::OPTION_GROUP,
114 'subscrpt_cancellation_feedback_comment',
115 array(
116 'type' => 'string',
117 'default' => '1',
118 'sanitize_callback' => 'sanitize_text_field',
119 )
120 );
121 register_setting(
122 self::OPTION_GROUP,
123 'subscrpt_cancellation_reasons',
124 array(
125 'type' => 'array',
126 'default' => array(),
127 'sanitize_callback' => array( __CLASS__, 'sanitize_reasons' ),
128 )
129 );
130
131 /**
132 * Fires so add-ons can register options into the Cancellation Flow group.
133 *
134 * Anything registered here is saved by this page and by no other, which
135 * is the whole point of the separate group.
136 *
137 * @param string $group The settings group name.
138 */
139 do_action( 'subscrpt_register_cancellation_settings', self::OPTION_GROUP );
140 }
141
142 /**
143 * Sanitize the cancellation reason list.
144 *
145 * The editor posts the ordered list as a JSON string. Each label is
146 * sanitized, each key comes from the posted key (or the label) and is made
147 * unique, and empty labels are dropped.
148 *
149 * `other` is reserved: the survey adds "Other" by itself while the comment
150 * box is on (Cancellation::get_reasons()), so a saved one would show twice,
151 * or show with no box to explain it in.
152 *
153 * Null means the list was not in the submitted form at all — the Offers tab
154 * posts to the same settings group without rendering this field — and
155 * options.php would save that null as an empty list. The stored list is kept.
156 *
157 * Pro checks for this method to know free owns the option.
158 *
159 * @param mixed $value JSON string, array, or null when not posted.
160 * @return array<int,array{key:string,label:string}>
161 */
162 public static function sanitize_reasons( $value ) {
163 if ( null === $value ) {
164 $stored = get_option( 'subscrpt_cancellation_reasons', array() );
165 return is_array( $stored ) ? $stored : array();
166 }
167
168 // options.php has already unslashed the POST; unslashing again would
169 // strip the backslashes JSON uses to escape a quote in a label.
170 $decoded = is_string( $value ) ? json_decode( $value, true ) : $value;
171 if ( ! is_array( $decoded ) ) {
172 return array();
173 }
174
175 $reasons = array();
176 $seen = array();
177 foreach ( $decoded as $item ) {
178 if ( ! is_array( $item ) ) {
179 continue;
180 }
181
182 $label = isset( $item['label'] ) ? sanitize_text_field( $item['label'] ) : '';
183 if ( '' === $label ) {
184 continue;
185 }
186
187 $key = ! empty( $item['key'] ) ? sanitize_key( $item['key'] ) : sanitize_key( $label );
188 if ( Cancellation::OTHER_KEY === $key ) {
189 continue;
190 }
191 if ( '' === $key ) {
192 $key = 'reason';
193 }
194 if ( isset( $seen[ $key ] ) ) {
195 $key .= '_' . count( $reasons );
196 }
197 $seen[ $key ] = true;
198
199 $reasons[] = array(
200 'key' => $key,
201 'label' => $label,
202 );
203 }
204
205 return $reasons;
206 }
207
208 /**
209 * Enqueue assets only on this page.
210 *
211 * Reuses the shared component bundle plus the settings stylesheet, which
212 * already styles every field type rendered here.
213 *
214 * @param string $hook Current admin page hook.
215 * @return void
216 */
217 public function enqueue_assets( $hook ) {
218 if ( ! $this->hook || $hook !== $this->hook ) {
219 return;
220 }
221
222 wp_enqueue_style( 'subscrpt_admin_components' );
223 wp_enqueue_script( 'subscrpt_admin_components' );
224
225 wp_enqueue_style(
226 'subscrpt_admin_settings_css',
227 SUBSCRPT_ASSETS . '/css/admin-settings.css',
228 array( 'subscrpt_admin_components' ),
229 SUBSCRPT_VERSION
230 );
231
232 // Keeps the Reasons tab's list in step with the editor before saving.
233 wp_enqueue_script(
234 'subscrpt_cancellation_flow_js',
235 SUBSCRPT_ASSETS . '/js/admin/cancellation-flow.js',
236 array( 'subscrpt_admin_components' ),
237 SUBSCRPT_VERSION,
238 true
239 );
240 }
241
242 /**
243 * The tabs this page shows.
244 *
245 * One for now. Kept as a list so adding the next one is a data change.
246 *
247 * @return array<string,array{label:string}>
248 */
249 public static function tabs() {
250 return array(
251 'reasons' => array( 'label' => __( 'Reasons', 'subscription' ) ),
252 'offers' => array( 'label' => __( 'Offers', 'subscription' ) ),
253 );
254 }
255
256 /**
257 * The Reasons tab field: the editable reason list.
258 *
259 * Free, with or without Pro: this page registers and sanitizes the option
260 * (sanitize_reasons()), and Cancellation::get_configured_reasons() reads it.
261 * The automatic "Other" is not part of the list, so it is not edited here.
262 *
263 * @return array
264 */
265 public static function reasons_field() {
266 return array(
267 'id' => 'subscrpt_cancellation_reasons',
268 'title' => __( 'Cancellation Reasons', 'subscription' ),
269 'description' => __( 'Reasons offered in the cancellation survey form. Shown when Cancellation Survey is enabled. "Other" is added automatically while Survey Comment Box is on.', 'subscription' ),
270 'value' => Cancellation::get_configured_reasons(),
271 'modal' => true,
272 'button_label' => __( 'Manage reasons', 'subscription' ),
273 'modal_title' => __( 'Cancellation Reasons', 'subscription' ),
274 'add_placeholder' => __( 'Add a reason…', 'subscription' ),
275 'add_label' => __( 'Add reason', 'subscription' ),
276 'empty_text' => __( 'No reasons yet. Add one below.', 'subscription' ),
277 );
278 }
279
280 /**
281 * The Offers tab fields: one retention discount.
282 *
283 * Pro-locked like the rest of this page's write settings - Pro registers and
284 * sanitizes them, and only Pro turns an accepted offer into a coupon.
285 *
286 * @return array<int,array{type:string,field_data:array}>
287 */
288 public static function offer_fields() {
289 $pro_locked = ! subscrpt_pro_activated();
290
291 return array(
292 array(
293 'type' => 'toggle',
294 'field_data' => array(
295 'id' => 'subscrpt_cancellation_offer_enabled',
296 'title' => __( 'Retention Offer', 'subscription' ),
297 'label' => __( 'Offer a discount before cancelling', 'subscription' ),
298 'description' => __( 'Show the customer a one-off discount code when they start cancelling. Accepting it keeps the subscription.', 'subscription' ),
299 'value' => '1',
300 'checked' => self::offer_enabled(),
301 'pro_locked' => $pro_locked,
302 ),
303 ),
304 array(
305 'type' => 'input',
306 'field_data' => array(
307 'id' => 'subscrpt_cancellation_offer_percent',
308 'title' => __( 'Discount', 'subscription' ),
309 'type' => 'number',
310 'description' => __( 'Percentage off, applied store-wide by the generated coupon.', 'subscription' ),
311 'value' => self::offer_percent(),
312 'attrs' => array(
313 'min' => '1',
314 'max' => '100',
315 'step' => '1',
316 ),
317 'pro_locked' => $pro_locked,
318 ),
319 ),
320 array(
321 'type' => 'input',
322 'field_data' => array(
323 'id' => 'subscrpt_cancellation_offer_days',
324 'title' => __( 'Valid for', 'subscription' ),
325 'type' => 'number',
326 'description' => __( 'Days before the generated coupon expires.', 'subscription' ),
327 'value' => self::offer_days(),
328 'attrs' => array(
329 'min' => '1',
330 'max' => '365',
331 'step' => '1',
332 ),
333 'pro_locked' => $pro_locked,
334 ),
335 ),
336 );
337 }
338
339 /**
340 * Whether a retention offer should be shown.
341 *
342 * Free stores and reads the settings so the UI round-trips, but only ever
343 * answers false without Pro - Pro is what turns an accepted offer into a
344 * coupon, so offering one free would be a promise nothing keeps.
345 *
346 * @return bool
347 */
348 public static function offer_enabled() {
349 if ( ! subscrpt_pro_activated() ) {
350 return false;
351 }
352
353 return '1' === get_option( 'subscrpt_cancellation_offer_enabled', '' ) && self::offer_percent() > 0;
354 }
355
356 /**
357 * Configured discount percentage.
358 *
359 * @return int
360 */
361 public static function offer_percent() {
362 return max( 0, min( 100, (int) get_option( 'subscrpt_cancellation_offer_percent', 20 ) ) );
363 }
364
365 /**
366 * Configured coupon lifetime in days.
367 *
368 * @return int
369 */
370 public static function offer_days() {
371 return max( 1, (int) get_option( 'subscrpt_cancellation_offer_days', 7 ) );
372 }
373
374 /**
375 * The flow-wide options shown in the sidebar, in render order.
376 *
377 * @return array<int,array{type:string,field_data:array}>
378 */
379 public static function sidebar_fields() {
380 $pro_locked = ! subscrpt_pro_activated();
381
382 return array(
383 array(
384 'type' => 'select',
385 'field_data' => array(
386 'id' => 'subscrpt_cancellation_delay',
387 'title' => __( 'Cancellation Timing', 'subscription' ),
388 'description' => __( 'When a subscription is cancelled, choose when it actually ends.', 'subscription' ),
389 'options' => array(
390 '24h' => __( 'After 24 hours', 'subscription' ),
391 'instant' => __( 'Immediately', 'subscription' ),
392 'period' => __( 'At end of billing period (before next renewal)', 'subscription' ),
393 ),
394 'selected' => esc_attr( Cancellation::get_settings( 'subscrpt_cancellation_delay' ) ),
395 'pro_locked' => $pro_locked,
396 ),
397 ),
398 array(
399 'type' => 'toggle',
400 'field_data' => array(
401 'id' => 'subscrpt_cancellation_feedback_enabled',
402 'title' => __( 'Cancellation Survey', 'subscription' ),
403 'label' => __( 'Ask customers why they are cancelling', 'subscription' ),
404 'description' => __( 'Show a short cancellation survey when a customer cancels a subscription, and record the reason for churn tracking.', 'subscription' ),
405 'value' => '1',
406 'checked' => Cancellation::is_feedback_enabled(),
407 ),
408 ),
409 array(
410 'type' => 'toggle',
411 'field_data' => array(
412 'id' => 'subscrpt_cancellation_feedback_comment',
413 'title' => __( 'Survey Comment Box', 'subscription' ),
414 'label' => __( 'Allow an additional comment', 'subscription' ),
415 'description' => __( 'Show an optional free-text comment field in the cancellation survey.', 'subscription' ),
416 'value' => '1',
417 'checked' => Cancellation::is_feedback_comment_enabled(),
418 ),
419 ),
420 );
421 }
422
423 /**
424 * Render the page: shared header + the flow view.
425 *
426 * @return void
427 */
428 public function render_page() {
429 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view routing, no state change.
430 $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'reasons';
431
432 $tabs = self::tabs();
433 if ( ! isset( $tabs[ $active_tab ] ) ) {
434 $active_tab = 'reasons';
435 }
436
437 $menu = new Menu();
438 if ( method_exists( $menu, 'render_admin_header' ) ) {
439 $menu->render_admin_header(
440 '',
441 '',
442 array( array( 'label' => __( 'Cancellation Flow', 'subscription' ) ) )
443 );
444 }
445
446 $page_url = admin_url( 'admin.php?page=' . self::SLUG );
447
448 include __DIR__ . '/views/cancellation-flow.php';
449 }
450 }
451