# subscription/2.3.0/includes/Admin/CancellationFlow.php

Subscriptions for WooCommerce with Stripe Recurring Payments, version 2.3.0. 505 lines.

- Page: https://pluginprobe.com/plugins/subscription/2.3.0/code/includes/Admin/CancellationFlow.php
- Raw: https://pluginprobe.com/plugins/subscription/2.3.0/raw/includes/Admin/CancellationFlow.php
- Modified: 2026-10-06T09:30:32+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/subscription/2.3.0/code/includes/Admin/CancellationFlow.php#L10-L20`.

```php
<?php
/**
 * Cancellation Flow - admin page.
 *
 * The home of everything that happens when a customer cancels: the reason list
 * customers pick from, and the flow-wide options beside it.
 *
 * These settings used to live on Settings -> Customers -> Cancellation. The
 * move keeps every option key byte-identical, so nothing that reads them had to
 * change - see Illuminate\Cancellation::get_settings() and ::get_reasons(),
 * which remain the only readers.
 *
 * What did have to change is the settings GROUP. wp-admin/options.php writes
 * `null` to every option registered in the submitted group that is absent from
 * the POST, so leaving these four in `wp_subscription_settings` would mean
 * saving the Settings page wiped them - and saving this page wiped everything
 * else. They are registered in their own group instead (see OPTION_GROUP), and
 * Pro registers its options into the same group.
 *
 * @package SpringDevs\Subscription\Admin
 */

namespace SpringDevs\Subscription\Admin;

use SpringDevs\Subscription\Illuminate\Cancellation;

/**
 * Cancellation Flow - admin page.
 */
class CancellationFlow {

	/**
	 * Admin page slug.
	 */
	const SLUG = 'wp-subscription-cancellation';

	/**
	 * Settings group for every option on this page.
	 *
	 * Deliberately separate from `wp_subscription_settings`: options.php nulls
	 * anything registered in a group but missing from the posted form, so two
	 * pages must never share one group.
	 */
	const OPTION_GROUP = 'wp_subscription_cancellation_settings';

	/**
	 * The page hook returned by add_submenu_page (for the enqueue gate).
	 *
	 * @var string
	 */
	private $hook = '';

	/**
	 * Initialize the class.
	 */
	public function __construct() {
		add_action( 'admin_menu', array( $this, 'register_page' ), 11 );
		add_filter( 'subscrpt_submenu_order', array( $this, 'position_submenu' ) );
		add_action( 'admin_init', array( $this, 'register_settings' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
	}

	/**
	 * Register the Cancellation Flow submenu under the WPSubscription top menu.
	 *
	 * @return void
	 */
	public function register_page() {
		$this->hook = (string) add_submenu_page(
			'wp-subscription',
			__( 'Cancellation Flow', 'subscription' ),
			__( 'Cancellation Flow', 'subscription' ),
			'manage_options',
			self::SLUG,
			array( $this, 'render_page' )
		);
	}

	/**
	 * Position the item within the WPSubscription submenu order.
	 *
	 * @param array $order Ordered slug => position map.
	 * @return array
	 */
	public function position_submenu( $order ) {
		if ( is_array( $order ) ) {
			$order[ self::SLUG ] = 70;
		}
		return $order;
	}

	/**
	 * Register the free options this page owns.
	 *
	 * Same option keys as before the move, new group. Pro registers
	 * `subscrpt_cancellation_delay` into this same group from its own
	 * Cancellation class. The reason list used to be Pro's too; it is free's now,
	 * and Pro only registers it against a free version without sanitize_reasons().
	 *
	 * @return void
	 */
	public function register_settings() {
		register_setting(
			self::OPTION_GROUP,
			'subscrpt_cancellation_feedback_enabled',
			array(
				'type'              => 'string',
				'default'           => '1',
				'sanitize_callback' => 'sanitize_text_field',
			)
		);
		register_setting(
			self::OPTION_GROUP,
			'subscrpt_cancellation_feedback_comment',
			array(
				'type'              => 'string',
				'default'           => '1',
				'sanitize_callback' => 'sanitize_text_field',
			)
		);
		register_setting(
			self::OPTION_GROUP,
			'subscrpt_cancellation_reasons',
			array(
				'type'              => 'array',
				'default'           => array(),
				'sanitize_callback' => array( __CLASS__, 'sanitize_reasons' ),
			)
		);

		/**
		 * Fires so add-ons can register options into the Cancellation Flow group.
		 *
		 * Anything registered here is saved by this page and by no other, which
		 * is the whole point of the separate group.
		 *
		 * @param string $group The settings group name.
		 */
		do_action( 'subscrpt_register_cancellation_settings', self::OPTION_GROUP );
	}

	/**
	 * Sanitize the cancellation reason list.
	 *
	 * The editor posts the ordered list as a JSON string. Each label is
	 * sanitized, each key comes from the posted key (or the label) and is made
	 * unique, and empty labels are dropped.
	 *
	 * `other` is reserved: the survey adds "Other" by itself while the comment
	 * box is on (Cancellation::get_reasons()), so a saved one would show twice,
	 * or show with no box to explain it in.
	 *
	 * Null means the list was not in the submitted form at all — the Offers tab
	 * posts to the same settings group without rendering this field — and
	 * options.php would save that null as an empty list. The stored list is kept.
	 *
	 * Pro checks for this method to know free owns the option.
	 *
	 * @param mixed $value JSON string, array, or null when not posted.
	 * @return array<int,array{key:string,label:string}>
	 */
	public static function sanitize_reasons( $value ) {
		if ( null === $value ) {
			$stored = get_option( 'subscrpt_cancellation_reasons', array() );
			return is_array( $stored ) ? $stored : array();
		}

		// options.php has already unslashed the POST; unslashing again would
		// strip the backslashes JSON uses to escape a quote in a label.
		$decoded = is_string( $value ) ? json_decode( $value, true ) : $value;
		if ( ! is_array( $decoded ) ) {
			return array();
		}

		$reasons = array();
		$seen    = array();
		foreach ( $decoded as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$label = isset( $item['label'] ) ? sanitize_text_field( $item['label'] ) : '';
			if ( '' === $label ) {
				continue;
			}

			$key = ! empty( $item['key'] ) ? sanitize_key( $item['key'] ) : sanitize_key( $label );
			if ( Cancellation::OTHER_KEY === $key ) {
				continue;
			}
			if ( '' === $key ) {
				$key = 'reason';
			}
			if ( isset( $seen[ $key ] ) ) {
				$key .= '_' . count( $reasons );
			}
			$seen[ $key ] = true;

			$reasons[] = array(
				'key'   => $key,
				'label' => $label,
			);
		}

		return $reasons;
	}

	/**
	 * Enqueue assets only on this page.
	 *
	 * Reuses the shared component bundle plus the settings stylesheet, which
	 * already styles every field type rendered here.
	 *
	 * @param string $hook Current admin page hook.
	 * @return void
	 */
	public function enqueue_assets( $hook ) {
		if ( ! $this->hook || $hook !== $this->hook ) {
			return;
		}

		wp_enqueue_style( 'subscrpt_admin_components' );
		wp_enqueue_script( 'subscrpt_admin_components' );

		wp_enqueue_style(
			'subscrpt_admin_settings_css',
			SUBSCRPT_ASSETS . '/css/admin-settings.css',
			array( 'subscrpt_admin_components' ),
			SUBSCRPT_VERSION
		);

		// Keeps the Reasons tab's list in step with the editor before saving.
		wp_enqueue_script(
			'subscrpt_cancellation_flow_js',
			SUBSCRPT_ASSETS . '/js/admin/cancellation-flow.js',
			array( 'subscrpt_admin_components' ),
			SUBSCRPT_VERSION,
			true
		);
	}

	/**
	 * The tabs this page shows.
	 *
	 * One for now. Kept as a list so adding the next one is a data change.
	 *
	 * @return array<string,array{label:string}>
	 */
	public static function tabs() {
		return array(
			'reasons' => array( 'label' => __( 'Reasons', 'subscription' ) ),
			'offers'  => array( 'label' => __( 'Offers', 'subscription' ) ),
		);
	}

	/**
	 * The Reasons tab field: the editable reason list.
	 *
	 * Free, with or without Pro: this page registers and sanitizes the option
	 * (sanitize_reasons()), and Cancellation::get_configured_reasons() reads it.
	 * The automatic "Other" is not part of the list, so it is not edited here.
	 *
	 * @return array
	 */
	public static function reasons_field() {
		return array(
			'id'              => 'subscrpt_cancellation_reasons',
			'title'           => __( 'Cancellation Reasons', 'subscription' ),
			'description'     => __( 'Reasons offered in the cancellation survey form. Shown when Cancellation Survey is enabled. "Other" is added automatically while Survey Comment Box is on.', 'subscription' ),
			'value'           => Cancellation::get_configured_reasons(),
			'modal'           => true,
			'button_label'    => __( 'Manage reasons', 'subscription' ),
			'modal_title'     => __( 'Cancellation Reasons', 'subscription' ),
			'add_placeholder' => __( 'Add a reason…', 'subscription' ),
			'add_label'       => __( 'Add reason', 'subscription' ),
			'empty_text'      => __( 'No reasons yet. Add one below.', 'subscription' ),
		);
	}

	/**
	 * The Offers tab fields: one retention discount.
	 *
	 * Pro-locked like the rest of this page's write settings - Pro registers and
	 * sanitizes them, and only Pro turns an accepted offer into a coupon.
	 *
	 * @return array<int,array{type:string,field_data:array}>
	 */
	public static function offer_fields() {
		$pro_locked = ! subscrpt_pro_activated();

		return array(
			array(
				'type'       => 'toggle',
				'field_data' => array(
					'id'          => 'subscrpt_cancellation_offer_enabled',
					'title'       => __( 'Retention Offer', 'subscription' ),
					'label'       => __( 'Offer a discount before cancelling', 'subscription' ),
					'description' => __( 'Show the customer a one-off discount code when they start cancelling. Accepting it keeps the subscription.', 'subscription' ),
					'value'       => '1',
					'checked'     => self::offer_enabled(),
					'pro_locked'  => $pro_locked,
				),
			),
			array(
				'type'       => 'join',
				'field_data' => array(
					'title'       => __( 'Discount', 'subscription' ),
					'description' => __( 'Percentage off, or a flat amount in the store currency, applied by the generated coupon.', 'subscription' ),
					'elements'    => array(
						SettingsHelper::inp_element(
							array(
								'id'         => 'subscrpt_cancellation_offer_percent',
								'value'      => self::offer_percent(),
								'type'       => 'number',
								'style'      => 'min-width:170px;width:170px;' . ( 'fixed' === self::offer_type() ? 'display:none;' : '' ),
								'attributes' => array(
									'min'  => '1',
									'max'  => '100',
									'step' => '1',
								),
							),
							true
						),
						SettingsHelper::inp_element(
							array(
								'id'         => 'subscrpt_cancellation_offer_amount',
								'value'      => wc_format_decimal( self::offer_amount(), wc_get_price_decimals() ),
								'type'       => 'number',
								'style'      => 'min-width:170px;width:170px;' . ( 'fixed' === self::offer_type() ? '' : 'display:none;' ),
								'attributes' => array(
									'min'  => '0',
									'step' => (string) ( 1 / pow( 10, wc_get_price_decimals() ) ),
								),
							),
							true
						),
						SettingsHelper::select_element(
							array(
								'id'       => 'subscrpt_cancellation_offer_type',
								'class'    => 'subscrpt-offer-type',
								'options'  => array(
									'percent' => __( 'Percentage (%)', 'subscription' ),
									/* translators: %s: store currency symbol. */
									'fixed'   => sprintf( __( 'Flat (%s)', 'subscription' ), get_woocommerce_currency_symbol() ),
								),
								'selected' => self::offer_type(),
							),
							true
						),
					),
					'pro_locked'  => $pro_locked,
				),
			),
			array(
				'type'       => 'input',
				'field_data' => array(
					'id'          => 'subscrpt_cancellation_offer_days',
					'title'       => __( 'Valid for', 'subscription' ),
					'type'        => 'number',
					'description' => __( 'Days before the generated coupon expires.', 'subscription' ),
					'value'       => self::offer_days(),
					'attrs'       => array(
						'min'  => '1',
						'max'  => '365',
						'step' => '1',
					),
					'pro_locked'  => $pro_locked,
				),
			),
		);
	}

	/**
	 * Whether a retention offer should be shown.
	 *
	 * Free stores and reads the settings so the UI round-trips, but only ever
	 * answers false without Pro - Pro is what turns an accepted offer into a
	 * coupon, so offering one free would be a promise nothing keeps.
	 *
	 * @return bool
	 */
	public static function offer_enabled() {
		if ( ! subscrpt_pro_activated() ) {
			return false;
		}

		$value = 'fixed' === self::offer_type() ? self::offer_amount() : self::offer_percent();

		return '1' === get_option( 'subscrpt_cancellation_offer_enabled', '' ) && $value > 0;
	}

	/**
	 * Configured discount type: `percent` or `fixed`.
	 *
	 * @return string
	 */
	public static function offer_type() {
		return 'fixed' === get_option( 'subscrpt_cancellation_offer_type', 'percent' ) ? 'fixed' : 'percent';
	}

	/**
	 * Configured flat discount amount, in the store currency.
	 *
	 * @return float
	 */
	public static function offer_amount() {
		return max( 0, (float) get_option( 'subscrpt_cancellation_offer_amount', 0 ) );
	}

	/**
	 * Configured discount percentage.
	 *
	 * @return int
	 */
	public static function offer_percent() {
		return max( 0, min( 100, (int) get_option( 'subscrpt_cancellation_offer_percent', 20 ) ) );
	}

	/**
	 * Configured coupon lifetime in days.
	 *
	 * @return int
	 */
	public static function offer_days() {
		return max( 1, (int) get_option( 'subscrpt_cancellation_offer_days', 7 ) );
	}

	/**
	 * The flow-wide options shown in the sidebar, in render order.
	 *
	 * @return array<int,array{type:string,field_data:array}>
	 */
	public static function sidebar_fields() {
		$pro_locked = ! subscrpt_pro_activated();

		return array(
			array(
				'type'       => 'select',
				'field_data' => array(
					'id'          => 'subscrpt_cancellation_delay',
					'title'       => __( 'Cancellation Timing', 'subscription' ),
					'description' => __( 'When a subscription is cancelled, choose when it actually ends.', 'subscription' ),
					'options'     => array(
						'24h'     => __( 'After 24 hours', 'subscription' ),
						'instant' => __( 'Immediately', 'subscription' ),
						'period'  => __( 'At end of billing period (before next renewal)', 'subscription' ),
					),
					'selected'    => esc_attr( Cancellation::get_settings( 'subscrpt_cancellation_delay' ) ),
					'pro_locked'  => $pro_locked,
				),
			),
			array(
				'type'       => 'toggle',
				'field_data' => array(
					'id'          => 'subscrpt_cancellation_feedback_enabled',
					'title'       => __( 'Cancellation Survey', 'subscription' ),
					'label'       => __( 'Ask customers why they are cancelling', 'subscription' ),
					'description' => __( 'Show a short cancellation survey when a customer cancels a subscription, and record the reason for churn tracking.', 'subscription' ),
					'value'       => '1',
					'checked'     => Cancellation::is_feedback_enabled(),
				),
			),
			array(
				'type'       => 'toggle',
				'field_data' => array(
					'id'          => 'subscrpt_cancellation_feedback_comment',
					'title'       => __( 'Survey Comment Box', 'subscription' ),
					'label'       => __( 'Allow an additional comment', 'subscription' ),
					'description' => __( 'Show an optional free-text comment field in the cancellation survey.', 'subscription' ),
					'value'       => '1',
					'checked'     => Cancellation::is_feedback_comment_enabled(),
				),
			),
		);
	}

	/**
	 * Render the page: shared header + the flow view.
	 *
	 * @return void
	 */
	public function render_page() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view routing, no state change.
		$active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'reasons';

		$tabs = self::tabs();
		if ( ! isset( $tabs[ $active_tab ] ) ) {
			$active_tab = 'reasons';
		}

		$menu = new Menu();
		if ( method_exists( $menu, 'render_admin_header' ) ) {
			$menu->render_admin_header(
				'',
				'',
				array( array( 'label' => __( 'Cancellation Flow', 'subscription' ) ) )
			);
		}

		$page_url = admin_url( 'admin.php?page=' . self::SLUG );

		include __DIR__ . '/views/cancellation-flow.php';
	}
}

```
