PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.3.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.3.0
2.4.0 2.3.0 2.2.0 2.1.0 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 All 65 releases
subscription / includes / Illuminate / Cancellation.php

Cancellation.php in Subscriptions for WooCommerce with Stripe Recurring Payments 2.3.0, at includes/Illuminate/Cancellation.php

809 lines 29.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Subscription cancellation handler.
4 *
5 * Owns the conversion of a pending-cancellation (`pe_cancelled`) subscription
6 * into a fully `cancelled` one. This is intentionally kept separate from the
7 * hourly *expiry* check in {@see Cron} so cancellation-related behaviour can grow
8 * here independently.
9 *
10 * Default flow (free): when a subscription enters `pe_cancelled` it is scheduled
11 * to be cancelled 24 hours later. The exact moment is filterable via
12 * `subscrpt_cancellation_time`, which the Pro plugin uses to offer "immediately",
13 * "after 24 hours", or "at the end of the billing period".
14 *
15 * @package SpringDevs\Subscription\Illuminate
16 */
17
18 namespace SpringDevs\Subscription\Illuminate;
19
20 // Exit if accessed directly.
21 defined( 'ABSPATH' ) || exit;
22
23 /**
24 * Class Cancellation
25 *
26 * @package SpringDevs\Subscription\Illuminate
27 */
28 class Cancellation {
29
30 /**
31 * Post meta storing the timestamp at which a pending cancellation becomes final.
32 *
33 * @var string
34 */
35 const CANCEL_AT_META = '_subscrpt_cancel_at';
36
37 /**
38 * Key of the "Other" reason, which the survey adds by itself.
39 *
40 * Reserved: it is never one of the store's own reasons. See get_reasons().
41 *
42 * @var string
43 */
44 const OTHER_KEY = 'other';
45
46 /**
47 * Initialize the class.
48 */
49 public function __construct() {
50 add_action( 'subscrpt_subscription_pending_cancellation', [ $this, 'schedule_cancellation' ] );
51 add_action( 'subscrpt_hourly_cron', [ $this, 'process_due_cancellations' ] );
52 add_action( 'subscrpt_subscription_resumed', [ $this, 'clear_scheduled_cancellation' ] );
53 add_action( 'before_single_subscrpt_content', [ $this, 'display_pending_cancellation_notice' ] );
54 add_action( 'before_single_subscrpt_content', [ $this, 'maybe_render_feedback_modal' ] );
55 add_action( 'wp_ajax_subscrpt_record_cancellation_feedback', [ $this, 'record_feedback' ] );
56 add_action( 'wp_ajax_subscrpt_record_cancellation_save', [ $this, 'record_save' ] );
57 add_action( 'wp_ajax_subscrpt_claim_cancellation_offer', [ $this, 'claim_offer' ] );
58 add_action( 'subscrpt_details_side_bottom', [ $this, 'render_admin_feedback_card' ] );
59 }
60
61 /**
62 * Fetch the most recent cancellation-feedback row for a subscription.
63 *
64 * @param int $subscription_id Subscription ID.
65 * @return array|null Associative row, or null when none exists.
66 */
67 public static function get_feedback( $subscription_id ) {
68 global $wpdb;
69
70 $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
71 $wpdb->prepare(
72 "SELECT reason_label, comment, created_at FROM {$wpdb->prefix}subscrpt_cancellation_feedback WHERE subscription_id = %d ORDER BY id DESC LIMIT 1",
73 (int) $subscription_id
74 ),
75 ARRAY_A
76 );
77
78 return $row ? $row : null;
79 }
80
81 /**
82 * Show the customer's cancellation reason on the admin subscription details page
83 * (bottom of the right-hand column).
84 *
85 * Renders only for cancelled / pending-cancellation subscriptions that have a
86 * recorded feedback row.
87 *
88 * @param array $ctx Subscription details context.
89 * @return void
90 */
91 public function render_admin_feedback_card( $ctx ) {
92 $subscription_id = (int) ( $ctx['subscription_id'] ?? 0 );
93 $status = $ctx['status'] ?? '';
94
95 if ( ! in_array( $status, [ 'cancelled', 'pe_cancelled' ], true ) ) {
96 return;
97 }
98
99 $feedback = self::get_feedback( $subscription_id );
100 if ( ! $feedback ) {
101 return;
102 }
103
104 $reason = (string) ( $feedback['reason_label'] ?? '' );
105 $comment = (string) ( $feedback['comment'] ?? '' );
106 $when = '';
107 if ( ! empty( $feedback['created_at'] ) ) {
108 $when = date_i18n(
109 get_option( 'date_format' ) . ' ' . get_option( 'time_format' ),
110 strtotime( get_date_from_gmt( $feedback['created_at'] ) )
111 );
112 }
113 ?>
114 <div class="subscrpt-card">
115 <div class="subscrpt-card__head"><?php esc_html_e( 'Cancellation Reason', 'subscription' ); ?></div>
116 <div class="subscrpt-card__body">
117 <div style="display:flex;gap:10px;align-items:flex-start;">
118 <span style="flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:8px;background:var(--wpsubs-brand-light);color:var(--wpsubs-brand);">
119 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
120 </span>
121 <div style="flex:1 1 auto;min-width:0;">
122 <p style="margin:0;font-weight:600;color:var(--wpsubs-text);word-break:break-word;">
123 <?php echo '' !== $reason ? esc_html( $reason ) : esc_html__( 'No reason selected', 'subscription' ); ?>
124 </p>
125 <?php if ( '' !== $when ) : ?>
126 <p style="margin:2px 0 0;font-size:12px;color:var(--wpsubs-text-subtle);">
127 <?php
128 printf(
129 /* translators: %s: date and time the feedback was submitted. */
130 esc_html__( 'Submitted %s', 'subscription' ),
131 esc_html( $when )
132 );
133 ?>
134 </p>
135 <?php endif; ?>
136 </div>
137 </div>
138 <?php if ( '' !== $comment ) : ?>
139 <blockquote style="margin:12px 0 0;padding:8px 12px;border-left:3px solid var(--wpsubs-border-strong);background:var(--wpsubs-surface-muted);border-radius:6px;color:var(--wpsubs-text-muted);font-size:13px;line-height:1.5;word-break:break-word;">
140 <?php echo wp_kses( nl2br( esc_html( $comment ) ), [ 'br' => [] ] ); ?>
141 </blockquote>
142 <?php endif; ?>
143 </div>
144 </div>
145 <?php
146 }
147
148 /**
149 * Render the cancellation-feedback modal on the single-subscription page.
150 *
151 * Only renders when the feature is enabled and the subscription is in a state
152 * that shows a Cancel button (pending/active/on_hold with user cancellation
153 * allowed). The markup is hidden by default; the frontend script intercepts the
154 * Cancel link click, opens it, and on confirm records feedback before following
155 * the original secure cancel URL.
156 *
157 * @param int $subscription_id Subscription ID.
158 * @return void
159 */
160 public function maybe_render_feedback_modal( $subscription_id ) {
161 if ( ! self::is_feedback_enabled() ) {
162 return;
163 }
164
165 $subscription_id = (int) $subscription_id;
166 $status = get_post_status( $subscription_id );
167
168 if ( ! in_array( $status, [ 'pending', 'active', 'on_hold' ], true ) ) {
169 return;
170 }
171
172 if ( 'no' === get_post_meta( $subscription_id, '_subscrpt_user_cancel', true ) ) {
173 return;
174 }
175
176 $reasons = self::get_reasons();
177 if ( empty( $reasons ) ) {
178 return;
179 }
180
181 wp_enqueue_style( 'subscrpt_cancellation_css', SUBSCRPT_ASSETS . '/css/cancellation.css', [], SUBSCRPT_VERSION );
182 wp_enqueue_script( 'subscrpt_cancellation_feedback', SUBSCRPT_ASSETS . '/js/frontend/cancellation-feedback.js', [], SUBSCRPT_VERSION, true );
183 wp_localize_script(
184 'subscrpt_cancellation_feedback',
185 'subscrptCancellationFeedback',
186 [
187 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
188 'nonce' => wp_create_nonce( 'subscrpt_cancellation_feedback' ),
189 'doneLabel' => __( 'Done', 'subscription' ),
190 ]
191 );
192 ?>
193 <div class="subscrpt-feedback-modal" id="subscrpt-feedback-modal" data-subscription="<?php echo esc_attr( $subscription_id ); ?>" hidden>
194 <div class="subscrpt-feedback-modal__overlay" data-subscrpt-feedback-dismiss></div>
195 <div class="subscrpt-feedback-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="subscrpt-feedback-title" aria-describedby="subscrpt-feedback-intro">
196 <div class="subscrpt-feedback-modal__header">
197 <h3 class="subscrpt-feedback-modal__title" id="subscrpt-feedback-title"><?php esc_html_e( 'Before you go', 'subscription' ); ?></h3>
198 <button type="button" class="subscrpt-feedback-modal__close" data-subscrpt-feedback-dismiss aria-label="<?php esc_attr_e( 'Close', 'subscription' ); ?>">
199 <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>
200 </button>
201 </div>
202 <?php if ( \SpringDevs\Subscription\Admin\CancellationFlow::offer_enabled() ) : ?>
203 <?php $subscrpt_offer_percent = \SpringDevs\Subscription\Admin\CancellationFlow::offer_percent(); ?>
204 <div class="subscrpt-feedback-modal__body" data-subscrpt-offer-step>
205 <p class="subscrpt-feedback-modal__intro">
206 <?php
207 if ( 'fixed' === \SpringDevs\Subscription\Admin\CancellationFlow::offer_type() ) {
208 printf(
209 /* translators: %s: formatted discount amount. */
210 esc_html__( 'Stay with us and take %s off your next order.', 'subscription' ),
211 wp_kses_post( wc_price( \SpringDevs\Subscription\Admin\CancellationFlow::offer_amount() ) )
212 );
213 } else {
214 printf(
215 /* translators: %s: discount percentage. */
216 esc_html__( 'Stay with us and take %s%% off your next order.', 'subscription' ),
217 esc_html( (string) $subscrpt_offer_percent )
218 );
219 }
220 ?>
221 </p>
222 <p class="subscrpt-feedback-modal__offer-note" data-subscrpt-offer-result hidden></p>
223 </div>
224 <div class="subscrpt-feedback-modal__footer" data-subscrpt-offer-step>
225 <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__offer-decline" data-subscrpt-offer-decline><?php esc_html_e( 'No thanks, continue', 'subscription' ); ?></button>
226 <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__offer-claim" data-subscrpt-offer-claim>
227 <?php esc_html_e( 'Claim discount', 'subscription' ); ?>
228 </button>
229 </div>
230 <?php endif; ?>
231
232 <div class="subscrpt-feedback-modal__body"<?php echo \SpringDevs\Subscription\Admin\CancellationFlow::offer_enabled() ? ' data-subscrpt-reason-step hidden' : ''; ?>>
233 <p class="subscrpt-feedback-modal__intro" id="subscrpt-feedback-intro"><?php esc_html_e( 'Please let us know why you are cancelling. Your feedback helps us improve.', 'subscription' ); ?></p>
234 <ul class="subscrpt-feedback-modal__reasons">
235 <?php foreach ( $reasons as $index => $reason ) : ?>
236 <?php
237 $reason_key = isset( $reason['key'] ) ? (string) $reason['key'] : '';
238 $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : '';
239 if ( '' === $reason_key || '' === $reason_label ) {
240 continue;
241 }
242 $input_id = 'subscrpt-feedback-reason-' . $index;
243 ?>
244 <li class="subscrpt-feedback-modal__reason">
245 <label class="subscrpt-feedback-modal__reason-label" for="<?php echo esc_attr( $input_id ); ?>">
246 <input type="radio" class="subscrpt-feedback-modal__radio" id="<?php echo esc_attr( $input_id ); ?>" name="subscrpt_feedback_reason" value="<?php echo esc_attr( $reason_key ); ?>" data-label="<?php echo esc_attr( $reason_label ); ?>" />
247 <span class="subscrpt-feedback-modal__reason-text"><?php echo esc_html( $reason_label ); ?></span>
248 </label>
249 </li>
250 <?php endforeach; ?>
251 </ul>
252 <?php if ( self::is_feedback_comment_enabled() ) : ?>
253 <textarea class="subscrpt-feedback-modal__comment" id="subscrpt-feedback-comment" rows="3" placeholder="<?php esc_attr_e( 'Additional comments (optional)', 'subscription' ); ?>"></textarea>
254 <?php endif; ?>
255 </div>
256 <div class="subscrpt-feedback-modal__footer"<?php echo \SpringDevs\Subscription\Admin\CancellationFlow::offer_enabled() ? ' data-subscrpt-reason-step hidden' : ''; ?>>
257 <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__confirm" id="subscrpt-feedback-confirm"><?php esc_html_e( 'Confirm cancellation', 'subscription' ); ?></button>
258 <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__keep" data-subscrpt-feedback-dismiss><?php esc_html_e( 'Keep subscription', 'subscription' ); ?></button>
259 </div>
260 </div>
261 </div>
262 <?php
263 }
264
265 /**
266 * AJAX: record a cancellation-feedback submission.
267 *
268 * Best-effort — the frontend proceeds with cancellation regardless of the
269 * outcome. Verifies the nonce and that the current user owns the subscription
270 * (or is an admin), snapshots the reason label so historic rows survive later
271 * reason edits, stores the row, and fires `subscrpt_cancellation_feedback_recorded`.
272 *
273 * @return void
274 */
275 public function record_feedback() {
276 check_ajax_referer( 'subscrpt_cancellation_feedback', 'nonce' );
277
278 $subscription_id = isset( $_POST['subscription_id'] ) ? absint( wp_unslash( $_POST['subscription_id'] ) ) : 0;
279 if ( $subscription_id <= 0 ) {
280 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
281 }
282
283 $subs_post = get_post( $subscription_id );
284 if ( ! $subs_post || 'subscrpt_order' !== $subs_post->post_type ) {
285 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
286 }
287
288 $author_id = (int) $subs_post->post_author;
289 if ( ! current_user_can( 'manage_options' ) && $author_id !== get_current_user_id() ) {
290 wp_send_json_error( [ 'message' => 'forbidden' ] );
291 }
292
293 $reason_key = isset( $_POST['reason_key'] ) ? sanitize_key( wp_unslash( $_POST['reason_key'] ) ) : '';
294 $comment = isset( $_POST['comment'] ) ? sanitize_textarea_field( wp_unslash( $_POST['comment'] ) ) : '';
295
296 // Snapshot the label from the current reason set so it survives later edits.
297 $reason_label = '';
298 foreach ( self::get_reasons() as $reason ) {
299 if ( isset( $reason['key'] ) && (string) $reason['key'] === $reason_key ) {
300 $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : '';
301 break;
302 }
303 }
304
305 global $wpdb;
306 $table = $wpdb->prefix . 'subscrpt_cancellation_feedback';
307 $data = [
308 'subscription_id' => $subscription_id,
309 'customer_id' => $author_id,
310 'reason_key' => $reason_key,
311 'reason_label' => $reason_label,
312 'comment' => $comment,
313 'created_at' => current_time( 'mysql', true ),
314 ];
315
316 // One row per subscription: overwrite any previous feedback (e.g. after a
317 // reactivate → cancel-again cycle) rather than accumulating a log, so the
318 // row always reflects the latest cancellation reason.
319 $existing_id = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
320 $wpdb->prepare(
321 "SELECT id FROM {$wpdb->prefix}subscrpt_cancellation_feedback WHERE subscription_id = %d ORDER BY id DESC LIMIT 1",
322 $subscription_id
323 )
324 );
325
326 if ( $existing_id ) {
327 // Clear any stray duplicates from earlier writes, keep the one we update.
328 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
329 $wpdb->prepare(
330 "DELETE FROM {$wpdb->prefix}subscrpt_cancellation_feedback WHERE subscription_id = %d AND id <> %d",
331 $subscription_id,
332 $existing_id
333 )
334 );
335 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
336 $table,
337 $data,
338 [ 'id' => $existing_id ],
339 [ '%d', '%d', '%s', '%s', '%s', '%s' ],
340 [ '%d' ]
341 );
342 $data['id'] = $existing_id;
343 } else {
344 $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
345 $table,
346 $data,
347 [ '%d', '%d', '%s', '%s', '%s', '%s' ]
348 );
349 $data['id'] = (int) $wpdb->insert_id;
350 }
351
352 /**
353 * Fires after a cancellation-feedback row is stored.
354 *
355 * @param int $subscription_id Subscription ID.
356 * @param array $data Stored feedback row.
357 */
358 do_action( 'subscrpt_cancellation_feedback_recorded', $subscription_id, $data );
359
360 wp_send_json_success( [ 'id' => $data['id'] ] );
361 }
362
363 /**
364 * AJAX: the customer accepted the retention offer.
365 *
366 * Free owns the request - nonce, ownership, throttle - and asks for an offer
367 * through `subscrpt_cancellation_offer`. Free itself has nothing to give: the
368 * coupon is Pro's, so without a listener this reports no offer rather than
369 * promising a discount that never arrives.
370 *
371 * @return void
372 */
373 public function claim_offer() {
374 check_ajax_referer( 'subscrpt_cancellation_feedback', 'nonce' );
375
376 $subscription_id = isset( $_POST['subscription_id'] ) ? absint( wp_unslash( $_POST['subscription_id'] ) ) : 0;
377 if ( $subscription_id <= 0 ) {
378 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
379 }
380
381 $subs_post = get_post( $subscription_id );
382 if ( ! $subs_post || 'subscrpt_order' !== $subs_post->post_type ) {
383 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
384 }
385
386 $author_id = (int) $subs_post->post_author;
387 if ( ! current_user_can( 'manage_options' ) && $author_id !== get_current_user_id() ) {
388 wp_send_json_error( [ 'message' => 'forbidden' ] );
389 }
390
391 /**
392 * Filters the retention offer handed to a cancelling customer.
393 *
394 * Return an array with a `code` to make the offer; anything falsy means no
395 * offer was issued and the customer continues to the reasons step.
396 *
397 * @param array|null $offer The offer, or null when none is issued.
398 * @param int $subscription_id Subscription ID.
399 * @param int $customer_id Subscription owner.
400 */
401 $offer = apply_filters( 'subscrpt_cancellation_offer', null, $subscription_id, $author_id );
402
403 if ( empty( $offer['code'] ) ) {
404 wp_send_json_error( [ 'message' => 'no_offer' ] );
405 }
406
407 // Accepting the offer is a save, and the strongest kind - report it even
408 // if the customer already dismissed the modal once today.
409 delete_transient( self::save_throttle_key( $subscription_id ) );
410 set_transient( self::save_throttle_key( $subscription_id ), 1, DAY_IN_SECONDS );
411
412 $reason_key = isset( $_POST['reason_key'] ) ? sanitize_key( wp_unslash( $_POST['reason_key'] ) ) : '';
413
414 $reason_label = '';
415 foreach ( self::get_reasons() as $reason ) {
416 if ( isset( $reason['key'] ) && (string) $reason['key'] === $reason_key ) {
417 $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : '';
418 break;
419 }
420 }
421
422 do_action(
423 'subscrpt_subscription_saved',
424 $subscription_id,
425 [
426 'subscription_id' => $subscription_id,
427 'customer_id' => $author_id,
428 'reason_key' => $reason_key,
429 'reason_label' => $reason_label,
430 'offer_accepted' => true,
431 'offer_code' => (string) $offer['code'],
432 ]
433 );
434
435 wp_send_json_success(
436 [
437 'code' => (string) $offer['code'],
438 'message' => isset( $offer['message'] ) ? (string) $offer['message'] : '',
439 ]
440 );
441 }
442
443 /**
444 * Transient guarding one save report per subscription per day.
445 *
446 * Every way out of the modal counts as a save - Keep subscription, the X, the
447 * overlay, Escape - so without this a customer idly opening and closing it
448 * would mail the store owner each time.
449 *
450 * @param int $subscription_id Subscription post ID.
451 * @return string
452 */
453 protected static function save_throttle_key( $subscription_id ) {
454 return 'subscrpt_save_reported_' . (int) $subscription_id;
455 }
456
457 /**
458 * AJAX: the customer backed out of cancelling.
459 *
460 * Records nothing - the reason list is only meaningful for an actual
461 * cancellation - but fires `subscrpt_subscription_saved` so the retention can
462 * be reported. Throttled to once a day per subscription.
463 *
464 * @return void
465 */
466 public function record_save() {
467 check_ajax_referer( 'subscrpt_cancellation_feedback', 'nonce' );
468
469 $subscription_id = isset( $_POST['subscription_id'] ) ? absint( wp_unslash( $_POST['subscription_id'] ) ) : 0;
470 if ( $subscription_id <= 0 ) {
471 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
472 }
473
474 $subs_post = get_post( $subscription_id );
475 if ( ! $subs_post || 'subscrpt_order' !== $subs_post->post_type ) {
476 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
477 }
478
479 $author_id = (int) $subs_post->post_author;
480 if ( ! current_user_can( 'manage_options' ) && $author_id !== get_current_user_id() ) {
481 wp_send_json_error( [ 'message' => 'forbidden' ] );
482 }
483
484 $throttle = self::save_throttle_key( $subscription_id );
485 if ( get_transient( $throttle ) ) {
486 wp_send_json_success( [ 'throttled' => true ] );
487 }
488 set_transient( $throttle, 1, DAY_IN_SECONDS );
489
490 $reason_key = isset( $_POST['reason_key'] ) ? sanitize_key( wp_unslash( $_POST['reason_key'] ) ) : '';
491
492 $reason_label = '';
493 foreach ( self::get_reasons() as $reason ) {
494 if ( isset( $reason['key'] ) && (string) $reason['key'] === $reason_key ) {
495 $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : '';
496 break;
497 }
498 }
499
500 $data = [
501 'subscription_id' => $subscription_id,
502 'customer_id' => $author_id,
503 'reason_key' => $reason_key,
504 'reason_label' => $reason_label,
505 'offer_accepted' => false,
506 ];
507
508 /**
509 * Fires when a customer opens the cancellation modal and backs out.
510 *
511 * Throttled to once a day per subscription, so a listener may treat each
512 * call as a distinct retention event.
513 *
514 * @param int $subscription_id Subscription ID.
515 * @param array $data Save context: the reason that had been
516 * selected (may be empty) and whether a
517 * retention offer was accepted.
518 */
519 do_action( 'subscrpt_subscription_saved', $subscription_id, $data );
520
521 wp_send_json_success( [ 'saved' => true ] );
522 }
523
524 /**
525 * Get Settings
526 *
527 * @param string $id Setting ID.
528 */
529 public static function get_settings( $id = '' ) {
530 $settings = [
531 'subscrpt_cancellation_delay' => subscrpt_pro_activated() ? get_option( 'subscrpt_cancellation_delay', '24h' ) : '24h',
532 'subscrpt_cancellation_feedback_enabled' => get_option( 'subscrpt_cancellation_feedback_enabled', '1' ),
533 'subscrpt_cancellation_feedback_comment' => get_option( 'subscrpt_cancellation_feedback_comment', '1' ),
534 ];
535 return ! empty( $id ) ? $settings[ $id ] ?? false : $settings;
536 }
537
538 /**
539 * Whether the cancellation-feedback prompt is enabled.
540 *
541 * A free feature — works with or without Pro.
542 *
543 * @return bool
544 */
545 public static function is_feedback_enabled() {
546 return '1' === self::get_settings( 'subscrpt_cancellation_feedback_enabled' );
547 }
548
549 /**
550 * Whether the optional comment box is shown in the feedback form. Defaults to on.
551 *
552 * @return bool
553 */
554 public static function is_feedback_comment_enabled() {
555 return '1' === self::get_settings( 'subscrpt_cancellation_feedback_comment' );
556 }
557
558 /**
559 * Built-in default cancellation reasons.
560 *
561 * Used when the store has not saved its own list. Written for a store
562 * selling goods on a subscription — the reasons a customer stops a product
563 * subscription — and without "Other", which get_reasons() adds by itself.
564 * Filterable so Pro and integrations can adjust the defaults.
565 *
566 * @return array<int,array{key:string,label:string}>
567 */
568 public static function default_reasons() {
569 $reasons = [
570 [
571 'key' => 'too_expensive',
572 'label' => __( 'Too expensive', 'subscription' ),
573 ],
574 [
575 'key' => 'too_much_product',
576 'label' => __( 'I have more than I need', 'subscription' ),
577 ],
578 [
579 'key' => 'quality_issues',
580 'label' => __( 'Not happy with the quality', 'subscription' ),
581 ],
582 [
583 'key' => 'delivery_issues',
584 'label' => __( 'Delivery took too long', 'subscription' ),
585 ],
586 [
587 'key' => 'found_better_deal',
588 'label' => __( 'Found a better deal elsewhere', 'subscription' ),
589 ],
590 [
591 'key' => 'taking_a_break',
592 'label' => __( 'Just taking a break', 'subscription' ),
593 ],
594 ];
595
596 /**
597 * Filter the built-in default cancellation reasons.
598 *
599 * @param array $reasons List of { key, label } reason entries.
600 */
601 return apply_filters( 'subscrpt_cancellation_default_reasons', $reasons );
602 }
603
604 /**
605 * The store's own reasons: the saved list, or the defaults when none is saved.
606 *
607 * This is what the Reasons editor edits. It is read with or without Pro —
608 * editing reasons is a free feature. An "other" entry saved before "Other"
609 * became automatic is dropped, so it can never show twice.
610 *
611 * @return array<int,array{key:string,label:string}>
612 */
613 public static function get_configured_reasons() {
614 $reasons = get_option( 'subscrpt_cancellation_reasons', [] );
615 if ( empty( $reasons ) || ! is_array( $reasons ) ) {
616 $reasons = self::default_reasons();
617 }
618
619 return array_values(
620 array_filter(
621 $reasons,
622 static function ( $reason ) {
623 return is_array( $reason ) && self::OTHER_KEY !== ( $reason['key'] ?? '' );
624 }
625 )
626 );
627 }
628
629 /**
630 * The reasons customers are offered.
631 *
632 * The store's reasons, then "Other" when the comment box is on — an answer
633 * that is none of the listed reasons needs the comment to say what it is,
634 * so without the box there is no "Other". Everything customer-facing reads
635 * this, including the label snapshot taken when feedback is recorded.
636 *
637 * @return array<int,array{key:string,label:string}>
638 */
639 public static function get_reasons() {
640 $reasons = self::get_configured_reasons();
641
642 if ( self::is_feedback_comment_enabled() ) {
643 $reasons[] = [
644 'key' => self::OTHER_KEY,
645 'label' => __( 'Other', 'subscription' ),
646 ];
647 }
648
649 return $reasons;
650 }
651
652 /**
653 * Record when a pending cancellation should become final.
654 *
655 * Runs whenever a subscription enters `pe_cancelled` (frontend, admin, or REST).
656 * If the resolved time is already due, the subscription is cancelled immediately.
657 *
658 * @param int $subscription_id Subscription ID.
659 * @return void
660 */
661 public function schedule_cancellation( $subscription_id ) {
662 $subscription_id = (int) $subscription_id;
663
664 /**
665 * Filter the timestamp at which a pending cancellation becomes a full cancellation.
666 *
667 * Return a Unix timestamp. A value at or before the current time cancels the
668 * subscription immediately. Defaults to 24 hours from now.
669 *
670 * @param int $cancel_at Unix timestamp for final cancellation.
671 * @param int $subscription_id Subscription ID.
672 */
673 $cancel_at = (int) apply_filters( 'subscrpt_cancellation_time', time() + DAY_IN_SECONDS, $subscription_id );
674
675 if ( $cancel_at <= time() ) {
676 $this->cancel( $subscription_id );
677 return;
678 }
679
680 update_post_meta( $subscription_id, self::CANCEL_AT_META, $cancel_at );
681 }
682
683 /**
684 * Hourly sweep: finalise any pending cancellations whose time has come.
685 *
686 * Picks up subscriptions whose `_subscrpt_cancel_at` is due, plus legacy
687 * `pe_cancelled` subscriptions (created before this meta existed) whose billing
688 * period has ended.
689 *
690 * @return void
691 */
692 public function process_due_cancellations() {
693 $subscriptions = get_posts(
694 [
695 'post_type' => 'subscrpt_order',
696 'post_status' => [ 'pe_cancelled' ],
697 'fields' => 'ids',
698 'numberposts' => -1,
699 'meta_query' => [
700 'relation' => 'OR',
701 [
702 'key' => self::CANCEL_AT_META,
703 'value' => time(),
704 'compare' => '<=',
705 'type' => 'NUMERIC',
706 ],
707 [
708 'relation' => 'AND',
709 [
710 'key' => self::CANCEL_AT_META,
711 'compare' => 'NOT EXISTS',
712 ],
713 [
714 'key' => '_subscrpt_next_date',
715 'value' => time(),
716 'compare' => '<=',
717 'type' => 'NUMERIC',
718 ],
719 ],
720 ],
721 ]
722 );
723
724 if ( empty( $subscriptions ) ) {
725 return;
726 }
727
728 // Ensure the mailer is ready so the cancellation email can be sent.
729 if ( function_exists( 'WC' ) && WC()->mailer() ) {
730 foreach ( $subscriptions as $subscription_id ) {
731 $this->cancel( (int) $subscription_id );
732 }
733 }
734 }
735
736 /**
737 * Finalise the cancellation of a single subscription.
738 *
739 * Guards against subscriptions that are no longer pending (e.g. reactivated).
740 *
741 * @param int $subscription_id Subscription ID.
742 * @return void
743 */
744 public function cancel( $subscription_id ) {
745 $subscription_id = (int) $subscription_id;
746
747 if ( 'pe_cancelled' === get_post_status( $subscription_id ) ) {
748 Action::status( 'cancelled', $subscription_id );
749 }
750
751 delete_post_meta( $subscription_id, self::CANCEL_AT_META );
752 }
753
754 /**
755 * Drop a scheduled cancellation when a subscription is reactivated.
756 *
757 * @param int $subscription_id Subscription ID.
758 * @return void
759 */
760 public function clear_scheduled_cancellation( $subscription_id ) {
761 delete_post_meta( (int) $subscription_id, self::CANCEL_AT_META );
762 }
763
764 /**
765 * Show a notice on the subscription details page when a cancellation is pending.
766 *
767 * Uses `_subscrpt_cancel_at` (the resolved final-cancellation time), falling back
768 * to the next renewal date for legacy subscriptions.
769 *
770 * @param int $subscription_id Subscription ID.
771 * @return void
772 */
773 public function display_pending_cancellation_notice( $subscription_id ) {
774 if ( 'pe_cancelled' !== get_post_status( $subscription_id ) ) {
775 return;
776 }
777
778 $cancel_at = (int) get_post_meta( $subscription_id, self::CANCEL_AT_META, true );
779 if ( ! $cancel_at ) {
780 $cancel_at = (int) get_post_meta( $subscription_id, '_subscrpt_next_date', true );
781 }
782
783 wp_enqueue_style( 'subscrpt_cancellation_css', SUBSCRPT_ASSETS . '/css/cancellation.css', [], SUBSCRPT_VERSION );
784 ?>
785 <div class="subscrpt-pending-cancel-notice" role="status">
786 <span class="subscrpt-pending-cancel-notice__icon" aria-hidden="true">
787 <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
788 </span>
789 <div class="subscrpt-pending-cancel-notice__body">
790 <p class="subscrpt-pending-cancel-notice__text">
791 <?php
792 if ( $cancel_at ) {
793 $effective = date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $cancel_at );
794 printf(
795 /* translators: %s: cancellation date and time. */
796 esc_html__( 'This subscription is scheduled to be cancelled on %s. You can continue accessing it until then.', 'subscription' ),
797 '<strong>' . esc_html( $effective ) . '</strong>'
798 );
799 } else {
800 esc_html_e( 'This subscription is scheduled to be cancelled at the end of the current billing period. You can continue accessing it until then.', 'subscription' );
801 }
802 ?>
803 </p>
804 </div>
805 </div>
806 <?php
807 }
808 }
809