PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.2.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.2.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
← All changes | includes/Illuminate/Cancellation.php +252 -27 1.10.7 → 2.2.0 View file →
@@ -34,8 +34,17 @@
34 34 */
35 35 const CANCEL_AT_META = '_subscrpt_cancel_at';
36 36
37 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 + /**
38 47 * Initialize the class.
39 48 */
40 49 public function __construct() {
41 50 add_action( 'subscrpt_subscription_pending_cancellation', [ $this, 'schedule_cancellation' ] );
@@ -43,8 +52,10 @@
43 52 add_action( 'subscrpt_subscription_resumed', [ $this, 'clear_scheduled_cancellation' ] );
44 53 add_action( 'before_single_subscrpt_content', [ $this, 'display_pending_cancellation_notice' ] );
45 54 add_action( 'before_single_subscrpt_content', [ $this, 'maybe_render_feedback_modal' ] );
46 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' ] );
47 58 add_action( 'subscrpt_details_side_bottom', [ $this, 'render_admin_feedback_card' ] );
48 59 }
49 60
50 61 /**
@@ -172,10 +183,11 @@
172 183 wp_localize_script(
173 184 'subscrpt_cancellation_feedback',
174 185 'subscrptCancellationFeedback',
175 186 [
176 - 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
177 - 'nonce' => wp_create_nonce( 'subscrpt_cancellation_feedback' ),
187 + 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
188 + 'nonce' => wp_create_nonce( 'subscrpt_cancellation_feedback' ),
189 + 'doneLabel' => __( 'Done', 'subscription' ),
178 190 ]
179 191 );
180 192 ?>
181 193 <div class="subscrpt-feedback-modal" id="subscrpt-feedback-modal" data-subscription="<?php echo esc_attr( $subscription_id ); ?>" hidden>
@@ -186,9 +198,31 @@
186 198 <button type="button" class="subscrpt-feedback-modal__close" data-subscrpt-feedback-dismiss aria-label="<?php esc_attr_e( 'Close', 'subscription' ); ?>">
187 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>
188 200 </button>
189 201 </div>
190 - <div class="subscrpt-feedback-modal__body">
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' : ''; ?>>
191 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>
192 226 <ul class="subscrpt-feedback-modal__reasons">
193 227 <?php foreach ( $reasons as $index => $reason ) : ?>
194 228 <?php
@@ -210,11 +244,11 @@
210 244 <?php if ( self::is_feedback_comment_enabled() ) : ?>
211 245 <textarea class="subscrpt-feedback-modal__comment" id="subscrpt-feedback-comment" rows="3" placeholder="<?php esc_attr_e( 'Additional comments (optional)', 'subscription' ); ?>"></textarea>
212 246 <?php endif; ?>
213 247 </div>
214 - <div class="subscrpt-feedback-modal__footer">
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>
215 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>
216 - <button type="button" class="subscrpt-feedback-modal__btn subscrpt-feedback-modal__confirm" id="subscrpt-feedback-confirm"><?php esc_html_e( 'Confirm cancellation', 'subscription' ); ?></button>
217 251 </div>
218 252 </div>
219 253 </div>
220 254 <?php
@@ -318,8 +352,169 @@
318 352 wp_send_json_success( [ 'id' => $data['id'] ] );
319 353 }
320 354
321 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 + /**
322 517 * Get Settings
323 518 *
324 519 * @param string $id Setting ID.
325 520 */
@@ -354,10 +549,12 @@
354 549
355 550 /**
356 551 * Built-in default cancellation reasons.
357 552 *
358 - * Used when no Pro-managed reason list is set. Filterable so Pro and
359 - * integrations can adjust the defaults.
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.
360 557 *
361 558 * @return array<int,array{key:string,label:string}>
362 559 */
363 560 public static function default_reasons() {
@@ -366,26 +563,26 @@
366 563 'key' => 'too_expensive',
367 564 'label' => __( 'Too expensive', 'subscription' ),
368 565 ],
369 566 [
370 - 'key' => 'missing_features',
371 - 'label' => __( 'Missing features I need', 'subscription' ),
567 + 'key' => 'too_much_product',
568 + 'label' => __( 'I have more than I need', 'subscription' ),
372 569 ],
373 570 [
374 - 'key' => 'found_alternative',
375 - 'label' => __( 'Found a better alternative', 'subscription' ),
571 + 'key' => 'quality_issues',
572 + 'label' => __( 'Not happy with the quality', 'subscription' ),
376 573 ],
377 574 [
378 - 'key' => 'no_longer_needed',
379 - 'label' => __( 'No longer needed', 'subscription' ),
575 + 'key' => 'delivery_issues',
576 + 'label' => __( 'Delivery took too long', 'subscription' ),
380 577 ],
381 578 [
382 - 'key' => 'technical_issues',
383 - 'label' => __( 'Technical issues', 'subscription' ),
579 + 'key' => 'found_better_deal',
580 + 'label' => __( 'Found a better deal elsewhere', 'subscription' ),
384 581 ],
385 582 [
386 - 'key' => 'other',
387 - 'label' => __( 'Other', 'subscription' ),
583 + 'key' => 'taking_a_break',
584 + 'label' => __( 'Just taking a break', 'subscription' ),
388 585 ],
389 586 ];
390 587
391 588 /**
@@ -396,25 +593,53 @@
396 593 return apply_filters( 'subscrpt_cancellation_default_reasons', $reasons );
397 594 }
398 595
399 596 /**
400 - * Resolve the active cancellation reason list.
597 + * The store's own reasons: the saved list, or the defaults when none is saved.
401 598 *
402 - * Uses the Pro-managed `subscrpt_cancellation_reasons` option when Pro is
403 - * active and the option is non-empty; otherwise falls back to the built-in
404 - * defaults so the feature works without Pro.
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.
405 602 *
406 603 * @return array<int,array{key:string,label:string}>
407 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 + */
408 631 public static function get_reasons() {
409 - if ( subscrpt_pro_activated() ) {
410 - $reasons = get_option( 'subscrpt_cancellation_reasons', [] );
411 - if ( ! empty( $reasons ) && is_array( $reasons ) ) {
412 - return $reasons;
413 - }
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 + ];
414 639 }
415 640
416 - return self::default_reasons();
641 + return $reasons;
417 642 }
418 643
419 644 /**
420 645 * Record when a pending cancellation should become final.