PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.4.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.4.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 +260 -27 1.11.2 → 2.4.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,39 @@
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 + 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' : ''; ?>>
191 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>
192 234 <ul class="subscrpt-feedback-modal__reasons">
193 235 <?php foreach ( $reasons as $index => $reason ) : ?>
194 236 <?php
@@ -210,11 +252,11 @@
210 252 <?php if ( self::is_feedback_comment_enabled() ) : ?>
211 253 <textarea class="subscrpt-feedback-modal__comment" id="subscrpt-feedback-comment" rows="3" placeholder="<?php esc_attr_e( 'Additional comments (optional)', 'subscription' ); ?>"></textarea>
212 254 <?php endif; ?>
213 255 </div>
214 - <div class="subscrpt-feedback-modal__footer">
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>
215 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>
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 259 </div>
218 260 </div>
219 261 </div>
220 262 <?php
@@ -318,8 +360,169 @@
318 360 wp_send_json_success( [ 'id' => $data['id'] ] );
319 361 }
320 362
321 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 + /**
322 525 * Get Settings
323 526 *
324 527 * @param string $id Setting ID.
325 528 */
@@ -354,10 +557,12 @@
354 557
355 558 /**
356 559 * Built-in default cancellation reasons.
357 560 *
358 - * Used when no Pro-managed reason list is set. Filterable so Pro and
359 - * integrations can adjust the defaults.
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.
360 565 *
361 566 * @return array<int,array{key:string,label:string}>
362 567 */
363 568 public static function default_reasons() {
@@ -366,26 +571,26 @@
366 571 'key' => 'too_expensive',
367 572 'label' => __( 'Too expensive', 'subscription' ),
368 573 ],
369 574 [
370 - 'key' => 'missing_features',
371 - 'label' => __( 'Missing features I need', 'subscription' ),
575 + 'key' => 'too_much_product',
576 + 'label' => __( 'I have more than I need', 'subscription' ),
372 577 ],
373 578 [
374 - 'key' => 'found_alternative',
375 - 'label' => __( 'Found a better alternative', 'subscription' ),
579 + 'key' => 'quality_issues',
580 + 'label' => __( 'Not happy with the quality', 'subscription' ),
376 581 ],
377 582 [
378 - 'key' => 'no_longer_needed',
379 - 'label' => __( 'No longer needed', 'subscription' ),
583 + 'key' => 'delivery_issues',
584 + 'label' => __( 'Delivery took too long', 'subscription' ),
380 585 ],
381 586 [
382 - 'key' => 'technical_issues',
383 - 'label' => __( 'Technical issues', 'subscription' ),
587 + 'key' => 'found_better_deal',
588 + 'label' => __( 'Found a better deal elsewhere', 'subscription' ),
384 589 ],
385 590 [
386 - 'key' => 'other',
387 - 'label' => __( 'Other', 'subscription' ),
591 + 'key' => 'taking_a_break',
592 + 'label' => __( 'Just taking a break', 'subscription' ),
388 593 ],
389 594 ];
390 595
391 596 /**
@@ -396,25 +601,53 @@
396 601 return apply_filters( 'subscrpt_cancellation_default_reasons', $reasons );
397 602 }
398 603
399 604 /**
400 - * Resolve the active cancellation reason list.
605 + * The store's own reasons: the saved list, or the defaults when none is saved.
401 606 *
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.
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.
405 610 *
406 611 * @return array<int,array{key:string,label:string}>
407 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 + */
408 639 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 - }
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 + ];
414 647 }
415 648
416 - return self::default_reasons();
649 + return $reasons;
417 650 }
418 651
419 652 /**
420 653 * Record when a pending cancellation should become final.