PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.1.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.1.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.1.0, at includes/Illuminate/Cancellation.php

801 lines 29.1 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 printf(
208 /* translators: %s: discount percentage. */
209 esc_html__( 'Stay with us and take %s%% off your next order.', 'subscription' ),
210 esc_html( (string) $subscrpt_offer_percent )
211 );
212 ?>
213 </p>
214 <p class="subscrpt-feedback-modal__offer-note" data-subscrpt-offer-result hidden></p>
215 </div>
216 <div class="subscrpt-feedback-modal__footer" data-subscrpt-offer-step>
217 <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>
218 <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__offer-claim" data-subscrpt-offer-claim>
219 <?php esc_html_e( 'Claim discount', 'subscription' ); ?>
220 </button>
221 </div>
222 <?php endif; ?>
223
224 <div class="subscrpt-feedback-modal__body"<?php echo \SpringDevs\Subscription\Admin\CancellationFlow::offer_enabled() ? ' data-subscrpt-reason-step hidden' : ''; ?>>
225 <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>
226 <ul class="subscrpt-feedback-modal__reasons">
227 <?php foreach ( $reasons as $index => $reason ) : ?>
228 <?php
229 $reason_key = isset( $reason['key'] ) ? (string) $reason['key'] : '';
230 $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : '';
231 if ( '' === $reason_key || '' === $reason_label ) {
232 continue;
233 }
234 $input_id = 'subscrpt-feedback-reason-' . $index;
235 ?>
236 <li class="subscrpt-feedback-modal__reason">
237 <label class="subscrpt-feedback-modal__reason-label" for="<?php echo esc_attr( $input_id ); ?>">
238 <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 ); ?>" />
239 <span class="subscrpt-feedback-modal__reason-text"><?php echo esc_html( $reason_label ); ?></span>
240 </label>
241 </li>
242 <?php endforeach; ?>
243 </ul>
244 <?php if ( self::is_feedback_comment_enabled() ) : ?>
245 <textarea class="subscrpt-feedback-modal__comment" id="subscrpt-feedback-comment" rows="3" placeholder="<?php esc_attr_e( 'Additional comments (optional)', 'subscription' ); ?>"></textarea>
246 <?php endif; ?>
247 </div>
248 <div class="subscrpt-feedback-modal__footer"<?php echo \SpringDevs\Subscription\Admin\CancellationFlow::offer_enabled() ? ' data-subscrpt-reason-step hidden' : ''; ?>>
249 <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__confirm" id="subscrpt-feedback-confirm"><?php esc_html_e( 'Confirm cancellation', 'subscription' ); ?></button>
250 <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__keep" data-subscrpt-feedback-dismiss><?php esc_html_e( 'Keep subscription', 'subscription' ); ?></button>
251 </div>
252 </div>
253 </div>
254 <?php
255 }
256
257 /**
258 * AJAX: record a cancellation-feedback submission.
259 *
260 * Best-effort — the frontend proceeds with cancellation regardless of the
261 * outcome. Verifies the nonce and that the current user owns the subscription
262 * (or is an admin), snapshots the reason label so historic rows survive later
263 * reason edits, stores the row, and fires `subscrpt_cancellation_feedback_recorded`.
264 *
265 * @return void
266 */
267 public function record_feedback() {
268 check_ajax_referer( 'subscrpt_cancellation_feedback', 'nonce' );
269
270 $subscription_id = isset( $_POST['subscription_id'] ) ? absint( wp_unslash( $_POST['subscription_id'] ) ) : 0;
271 if ( $subscription_id <= 0 ) {
272 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
273 }
274
275 $subs_post = get_post( $subscription_id );
276 if ( ! $subs_post || 'subscrpt_order' !== $subs_post->post_type ) {
277 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
278 }
279
280 $author_id = (int) $subs_post->post_author;
281 if ( ! current_user_can( 'manage_options' ) && $author_id !== get_current_user_id() ) {
282 wp_send_json_error( [ 'message' => 'forbidden' ] );
283 }
284
285 $reason_key = isset( $_POST['reason_key'] ) ? sanitize_key( wp_unslash( $_POST['reason_key'] ) ) : '';
286 $comment = isset( $_POST['comment'] ) ? sanitize_textarea_field( wp_unslash( $_POST['comment'] ) ) : '';
287
288 // Snapshot the label from the current reason set so it survives later edits.
289 $reason_label = '';
290 foreach ( self::get_reasons() as $reason ) {
291 if ( isset( $reason['key'] ) && (string) $reason['key'] === $reason_key ) {
292 $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : '';
293 break;
294 }
295 }
296
297 global $wpdb;
298 $table = $wpdb->prefix . 'subscrpt_cancellation_feedback';
299 $data = [
300 'subscription_id' => $subscription_id,
301 'customer_id' => $author_id,
302 'reason_key' => $reason_key,
303 'reason_label' => $reason_label,
304 'comment' => $comment,
305 'created_at' => current_time( 'mysql', true ),
306 ];
307
308 // One row per subscription: overwrite any previous feedback (e.g. after a
309 // reactivate → cancel-again cycle) rather than accumulating a log, so the
310 // row always reflects the latest cancellation reason.
311 $existing_id = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
312 $wpdb->prepare(
313 "SELECT id FROM {$wpdb->prefix}subscrpt_cancellation_feedback WHERE subscription_id = %d ORDER BY id DESC LIMIT 1",
314 $subscription_id
315 )
316 );
317
318 if ( $existing_id ) {
319 // Clear any stray duplicates from earlier writes, keep the one we update.
320 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
321 $wpdb->prepare(
322 "DELETE FROM {$wpdb->prefix}subscrpt_cancellation_feedback WHERE subscription_id = %d AND id <> %d",
323 $subscription_id,
324 $existing_id
325 )
326 );
327 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
328 $table,
329 $data,
330 [ 'id' => $existing_id ],
331 [ '%d', '%d', '%s', '%s', '%s', '%s' ],
332 [ '%d' ]
333 );
334 $data['id'] = $existing_id;
335 } else {
336 $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
337 $table,
338 $data,
339 [ '%d', '%d', '%s', '%s', '%s', '%s' ]
340 );
341 $data['id'] = (int) $wpdb->insert_id;
342 }
343
344 /**
345 * Fires after a cancellation-feedback row is stored.
346 *
347 * @param int $subscription_id Subscription ID.
348 * @param array $data Stored feedback row.
349 */
350 do_action( 'subscrpt_cancellation_feedback_recorded', $subscription_id, $data );
351
352 wp_send_json_success( [ 'id' => $data['id'] ] );
353 }
354
355 /**
356 * AJAX: the customer accepted the retention offer.
357 *
358 * Free owns the request - nonce, ownership, throttle - and asks for an offer
359 * through `subscrpt_cancellation_offer`. Free itself has nothing to give: the
360 * coupon is Pro's, so without a listener this reports no offer rather than
361 * promising a discount that never arrives.
362 *
363 * @return void
364 */
365 public function claim_offer() {
366 check_ajax_referer( 'subscrpt_cancellation_feedback', 'nonce' );
367
368 $subscription_id = isset( $_POST['subscription_id'] ) ? absint( wp_unslash( $_POST['subscription_id'] ) ) : 0;
369 if ( $subscription_id <= 0 ) {
370 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
371 }
372
373 $subs_post = get_post( $subscription_id );
374 if ( ! $subs_post || 'subscrpt_order' !== $subs_post->post_type ) {
375 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
376 }
377
378 $author_id = (int) $subs_post->post_author;
379 if ( ! current_user_can( 'manage_options' ) && $author_id !== get_current_user_id() ) {
380 wp_send_json_error( [ 'message' => 'forbidden' ] );
381 }
382
383 /**
384 * Filters the retention offer handed to a cancelling customer.
385 *
386 * Return an array with a `code` to make the offer; anything falsy means no
387 * offer was issued and the customer continues to the reasons step.
388 *
389 * @param array|null $offer The offer, or null when none is issued.
390 * @param int $subscription_id Subscription ID.
391 * @param int $customer_id Subscription owner.
392 */
393 $offer = apply_filters( 'subscrpt_cancellation_offer', null, $subscription_id, $author_id );
394
395 if ( empty( $offer['code'] ) ) {
396 wp_send_json_error( [ 'message' => 'no_offer' ] );
397 }
398
399 // Accepting the offer is a save, and the strongest kind - report it even
400 // if the customer already dismissed the modal once today.
401 delete_transient( self::save_throttle_key( $subscription_id ) );
402 set_transient( self::save_throttle_key( $subscription_id ), 1, DAY_IN_SECONDS );
403
404 $reason_key = isset( $_POST['reason_key'] ) ? sanitize_key( wp_unslash( $_POST['reason_key'] ) ) : '';
405
406 $reason_label = '';
407 foreach ( self::get_reasons() as $reason ) {
408 if ( isset( $reason['key'] ) && (string) $reason['key'] === $reason_key ) {
409 $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : '';
410 break;
411 }
412 }
413
414 do_action(
415 'subscrpt_subscription_saved',
416 $subscription_id,
417 [
418 'subscription_id' => $subscription_id,
419 'customer_id' => $author_id,
420 'reason_key' => $reason_key,
421 'reason_label' => $reason_label,
422 'offer_accepted' => true,
423 'offer_code' => (string) $offer['code'],
424 ]
425 );
426
427 wp_send_json_success(
428 [
429 'code' => (string) $offer['code'],
430 'message' => isset( $offer['message'] ) ? (string) $offer['message'] : '',
431 ]
432 );
433 }
434
435 /**
436 * Transient guarding one save report per subscription per day.
437 *
438 * Every way out of the modal counts as a save - Keep subscription, the X, the
439 * overlay, Escape - so without this a customer idly opening and closing it
440 * would mail the store owner each time.
441 *
442 * @param int $subscription_id Subscription post ID.
443 * @return string
444 */
445 protected static function save_throttle_key( $subscription_id ) {
446 return 'subscrpt_save_reported_' . (int) $subscription_id;
447 }
448
449 /**
450 * AJAX: the customer backed out of cancelling.
451 *
452 * Records nothing - the reason list is only meaningful for an actual
453 * cancellation - but fires `subscrpt_subscription_saved` so the retention can
454 * be reported. Throttled to once a day per subscription.
455 *
456 * @return void
457 */
458 public function record_save() {
459 check_ajax_referer( 'subscrpt_cancellation_feedback', 'nonce' );
460
461 $subscription_id = isset( $_POST['subscription_id'] ) ? absint( wp_unslash( $_POST['subscription_id'] ) ) : 0;
462 if ( $subscription_id <= 0 ) {
463 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
464 }
465
466 $subs_post = get_post( $subscription_id );
467 if ( ! $subs_post || 'subscrpt_order' !== $subs_post->post_type ) {
468 wp_send_json_error( [ 'message' => 'invalid_subscription' ] );
469 }
470
471 $author_id = (int) $subs_post->post_author;
472 if ( ! current_user_can( 'manage_options' ) && $author_id !== get_current_user_id() ) {
473 wp_send_json_error( [ 'message' => 'forbidden' ] );
474 }
475
476 $throttle = self::save_throttle_key( $subscription_id );
477 if ( get_transient( $throttle ) ) {
478 wp_send_json_success( [ 'throttled' => true ] );
479 }
480 set_transient( $throttle, 1, DAY_IN_SECONDS );
481
482 $reason_key = isset( $_POST['reason_key'] ) ? sanitize_key( wp_unslash( $_POST['reason_key'] ) ) : '';
483
484 $reason_label = '';
485 foreach ( self::get_reasons() as $reason ) {
486 if ( isset( $reason['key'] ) && (string) $reason['key'] === $reason_key ) {
487 $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : '';
488 break;
489 }
490 }
491
492 $data = [
493 'subscription_id' => $subscription_id,
494 'customer_id' => $author_id,
495 'reason_key' => $reason_key,
496 'reason_label' => $reason_label,
497 'offer_accepted' => false,
498 ];
499
500 /**
501 * Fires when a customer opens the cancellation modal and backs out.
502 *
503 * Throttled to once a day per subscription, so a listener may treat each
504 * call as a distinct retention event.
505 *
506 * @param int $subscription_id Subscription ID.
507 * @param array $data Save context: the reason that had been
508 * selected (may be empty) and whether a
509 * retention offer was accepted.
510 */
511 do_action( 'subscrpt_subscription_saved', $subscription_id, $data );
512
513 wp_send_json_success( [ 'saved' => true ] );
514 }
515
516 /**
517 * Get Settings
518 *
519 * @param string $id Setting ID.
520 */
521 public static function get_settings( $id = '' ) {
522 $settings = [
523 'subscrpt_cancellation_delay' => subscrpt_pro_activated() ? get_option( 'subscrpt_cancellation_delay', '24h' ) : '24h',
524 'subscrpt_cancellation_feedback_enabled' => get_option( 'subscrpt_cancellation_feedback_enabled', '1' ),
525 'subscrpt_cancellation_feedback_comment' => get_option( 'subscrpt_cancellation_feedback_comment', '1' ),
526 ];
527 return ! empty( $id ) ? $settings[ $id ] ?? false : $settings;
528 }
529
530 /**
531 * Whether the cancellation-feedback prompt is enabled.
532 *
533 * A free feature — works with or without Pro.
534 *
535 * @return bool
536 */
537 public static function is_feedback_enabled() {
538 return '1' === self::get_settings( 'subscrpt_cancellation_feedback_enabled' );
539 }
540
541 /**
542 * Whether the optional comment box is shown in the feedback form. Defaults to on.
543 *
544 * @return bool
545 */
546 public static function is_feedback_comment_enabled() {
547 return '1' === self::get_settings( 'subscrpt_cancellation_feedback_comment' );
548 }
549
550 /**
551 * Built-in default cancellation reasons.
552 *
553 * Used when the store has not saved its own list. Written for a store
554 * selling goods on a subscription — the reasons a customer stops a product
555 * subscription — and without "Other", which get_reasons() adds by itself.
556 * Filterable so Pro and integrations can adjust the defaults.
557 *
558 * @return array<int,array{key:string,label:string}>
559 */
560 public static function default_reasons() {
561 $reasons = [
562 [
563 'key' => 'too_expensive',
564 'label' => __( 'Too expensive', 'subscription' ),
565 ],
566 [
567 'key' => 'too_much_product',
568 'label' => __( 'I have more than I need', 'subscription' ),
569 ],
570 [
571 'key' => 'quality_issues',
572 'label' => __( 'Not happy with the quality', 'subscription' ),
573 ],
574 [
575 'key' => 'delivery_issues',
576 'label' => __( 'Delivery took too long', 'subscription' ),
577 ],
578 [
579 'key' => 'found_better_deal',
580 'label' => __( 'Found a better deal elsewhere', 'subscription' ),
581 ],
582 [
583 'key' => 'taking_a_break',
584 'label' => __( 'Just taking a break', 'subscription' ),
585 ],
586 ];
587
588 /**
589 * Filter the built-in default cancellation reasons.
590 *
591 * @param array $reasons List of { key, label } reason entries.
592 */
593 return apply_filters( 'subscrpt_cancellation_default_reasons', $reasons );
594 }
595
596 /**
597 * The store's own reasons: the saved list, or the defaults when none is saved.
598 *
599 * This is what the Reasons editor edits. It is read with or without Pro —
600 * editing reasons is a free feature. An "other" entry saved before "Other"
601 * became automatic is dropped, so it can never show twice.
602 *
603 * @return array<int,array{key:string,label:string}>
604 */
605 public static function get_configured_reasons() {
606 $reasons = get_option( 'subscrpt_cancellation_reasons', [] );
607 if ( empty( $reasons ) || ! is_array( $reasons ) ) {
608 $reasons = self::default_reasons();
609 }
610
611 return array_values(
612 array_filter(
613 $reasons,
614 static function ( $reason ) {
615 return is_array( $reason ) && self::OTHER_KEY !== ( $reason['key'] ?? '' );
616 }
617 )
618 );
619 }
620
621 /**
622 * The reasons customers are offered.
623 *
624 * The store's reasons, then "Other" when the comment box is on — an answer
625 * that is none of the listed reasons needs the comment to say what it is,
626 * so without the box there is no "Other". Everything customer-facing reads
627 * this, including the label snapshot taken when feedback is recorded.
628 *
629 * @return array<int,array{key:string,label:string}>
630 */
631 public static function get_reasons() {
632 $reasons = self::get_configured_reasons();
633
634 if ( self::is_feedback_comment_enabled() ) {
635 $reasons[] = [
636 'key' => self::OTHER_KEY,
637 'label' => __( 'Other', 'subscription' ),
638 ];
639 }
640
641 return $reasons;
642 }
643
644 /**
645 * Record when a pending cancellation should become final.
646 *
647 * Runs whenever a subscription enters `pe_cancelled` (frontend, admin, or REST).
648 * If the resolved time is already due, the subscription is cancelled immediately.
649 *
650 * @param int $subscription_id Subscription ID.
651 * @return void
652 */
653 public function schedule_cancellation( $subscription_id ) {
654 $subscription_id = (int) $subscription_id;
655
656 /**
657 * Filter the timestamp at which a pending cancellation becomes a full cancellation.
658 *
659 * Return a Unix timestamp. A value at or before the current time cancels the
660 * subscription immediately. Defaults to 24 hours from now.
661 *
662 * @param int $cancel_at Unix timestamp for final cancellation.
663 * @param int $subscription_id Subscription ID.
664 */
665 $cancel_at = (int) apply_filters( 'subscrpt_cancellation_time', time() + DAY_IN_SECONDS, $subscription_id );
666
667 if ( $cancel_at <= time() ) {
668 $this->cancel( $subscription_id );
669 return;
670 }
671
672 update_post_meta( $subscription_id, self::CANCEL_AT_META, $cancel_at );
673 }
674
675 /**
676 * Hourly sweep: finalise any pending cancellations whose time has come.
677 *
678 * Picks up subscriptions whose `_subscrpt_cancel_at` is due, plus legacy
679 * `pe_cancelled` subscriptions (created before this meta existed) whose billing
680 * period has ended.
681 *
682 * @return void
683 */
684 public function process_due_cancellations() {
685 $subscriptions = get_posts(
686 [
687 'post_type' => 'subscrpt_order',
688 'post_status' => [ 'pe_cancelled' ],
689 'fields' => 'ids',
690 'numberposts' => -1,
691 'meta_query' => [
692 'relation' => 'OR',
693 [
694 'key' => self::CANCEL_AT_META,
695 'value' => time(),
696 'compare' => '<=',
697 'type' => 'NUMERIC',
698 ],
699 [
700 'relation' => 'AND',
701 [
702 'key' => self::CANCEL_AT_META,
703 'compare' => 'NOT EXISTS',
704 ],
705 [
706 'key' => '_subscrpt_next_date',
707 'value' => time(),
708 'compare' => '<=',
709 'type' => 'NUMERIC',
710 ],
711 ],
712 ],
713 ]
714 );
715
716 if ( empty( $subscriptions ) ) {
717 return;
718 }
719
720 // Ensure the mailer is ready so the cancellation email can be sent.
721 if ( function_exists( 'WC' ) && WC()->mailer() ) {
722 foreach ( $subscriptions as $subscription_id ) {
723 $this->cancel( (int) $subscription_id );
724 }
725 }
726 }
727
728 /**
729 * Finalise the cancellation of a single subscription.
730 *
731 * Guards against subscriptions that are no longer pending (e.g. reactivated).
732 *
733 * @param int $subscription_id Subscription ID.
734 * @return void
735 */
736 public function cancel( $subscription_id ) {
737 $subscription_id = (int) $subscription_id;
738
739 if ( 'pe_cancelled' === get_post_status( $subscription_id ) ) {
740 Action::status( 'cancelled', $subscription_id );
741 }
742
743 delete_post_meta( $subscription_id, self::CANCEL_AT_META );
744 }
745
746 /**
747 * Drop a scheduled cancellation when a subscription is reactivated.
748 *
749 * @param int $subscription_id Subscription ID.
750 * @return void
751 */
752 public function clear_scheduled_cancellation( $subscription_id ) {
753 delete_post_meta( (int) $subscription_id, self::CANCEL_AT_META );
754 }
755
756 /**
757 * Show a notice on the subscription details page when a cancellation is pending.
758 *
759 * Uses `_subscrpt_cancel_at` (the resolved final-cancellation time), falling back
760 * to the next renewal date for legacy subscriptions.
761 *
762 * @param int $subscription_id Subscription ID.
763 * @return void
764 */
765 public function display_pending_cancellation_notice( $subscription_id ) {
766 if ( 'pe_cancelled' !== get_post_status( $subscription_id ) ) {
767 return;
768 }
769
770 $cancel_at = (int) get_post_meta( $subscription_id, self::CANCEL_AT_META, true );
771 if ( ! $cancel_at ) {
772 $cancel_at = (int) get_post_meta( $subscription_id, '_subscrpt_next_date', true );
773 }
774
775 wp_enqueue_style( 'subscrpt_cancellation_css', SUBSCRPT_ASSETS . '/css/cancellation.css', [], SUBSCRPT_VERSION );
776 ?>
777 <div class="subscrpt-pending-cancel-notice" role="status">
778 <span class="subscrpt-pending-cancel-notice__icon" aria-hidden="true">
779 <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>
780 </span>
781 <div class="subscrpt-pending-cancel-notice__body">
782 <p class="subscrpt-pending-cancel-notice__text">
783 <?php
784 if ( $cancel_at ) {
785 $effective = date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $cancel_at );
786 printf(
787 /* translators: %s: cancellation date and time. */
788 esc_html__( 'This subscription is scheduled to be cancelled on %s. You can continue accessing it until then.', 'subscription' ),
789 '<strong>' . esc_html( $effective ) . '</strong>'
790 );
791 } else {
792 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' );
793 }
794 ?>
795 </p>
796 </div>
797 </div>
798 <?php
799 }
800 }
801