get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT reason_label, comment, created_at FROM {$wpdb->prefix}subscrpt_cancellation_feedback WHERE subscription_id = %d ORDER BY id DESC LIMIT 1", (int) $subscription_id ), ARRAY_A ); return $row ? $row : null; } /** * Show the customer's cancellation reason on the admin subscription details page * (bottom of the right-hand column). * * Renders only for cancelled / pending-cancellation subscriptions that have a * recorded feedback row. * * @param array $ctx Subscription details context. * @return void */ public function render_admin_feedback_card( $ctx ) { $subscription_id = (int) ( $ctx['subscription_id'] ?? 0 ); $status = $ctx['status'] ?? ''; if ( ! in_array( $status, [ 'cancelled', 'pe_cancelled' ], true ) ) { return; } $feedback = self::get_feedback( $subscription_id ); if ( ! $feedback ) { return; } $reason = (string) ( $feedback['reason_label'] ?? '' ); $comment = (string) ( $feedback['comment'] ?? '' ); $when = ''; if ( ! empty( $feedback['created_at'] ) ) { $when = date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( get_date_from_gmt( $feedback['created_at'] ) ) ); } ?>

[] ] ); ?>
admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'subscrpt_cancellation_feedback' ), 'doneLabel' => __( 'Done', 'subscription' ), ] ); ?> 'invalid_subscription' ] ); } $subs_post = get_post( $subscription_id ); if ( ! $subs_post || 'subscrpt_order' !== $subs_post->post_type ) { wp_send_json_error( [ 'message' => 'invalid_subscription' ] ); } $author_id = (int) $subs_post->post_author; if ( ! current_user_can( 'manage_options' ) && $author_id !== get_current_user_id() ) { wp_send_json_error( [ 'message' => 'forbidden' ] ); } $reason_key = isset( $_POST['reason_key'] ) ? sanitize_key( wp_unslash( $_POST['reason_key'] ) ) : ''; $comment = isset( $_POST['comment'] ) ? sanitize_textarea_field( wp_unslash( $_POST['comment'] ) ) : ''; // Snapshot the label from the current reason set so it survives later edits. $reason_label = ''; foreach ( self::get_reasons() as $reason ) { if ( isset( $reason['key'] ) && (string) $reason['key'] === $reason_key ) { $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : ''; break; } } global $wpdb; $table = $wpdb->prefix . 'subscrpt_cancellation_feedback'; $data = [ 'subscription_id' => $subscription_id, 'customer_id' => $author_id, 'reason_key' => $reason_key, 'reason_label' => $reason_label, 'comment' => $comment, 'created_at' => current_time( 'mysql', true ), ]; // One row per subscription: overwrite any previous feedback (e.g. after a // reactivate → cancel-again cycle) rather than accumulating a log, so the // row always reflects the latest cancellation reason. $existing_id = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}subscrpt_cancellation_feedback WHERE subscription_id = %d ORDER BY id DESC LIMIT 1", $subscription_id ) ); if ( $existing_id ) { // Clear any stray duplicates from earlier writes, keep the one we update. $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "DELETE FROM {$wpdb->prefix}subscrpt_cancellation_feedback WHERE subscription_id = %d AND id <> %d", $subscription_id, $existing_id ) ); $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $table, $data, [ 'id' => $existing_id ], [ '%d', '%d', '%s', '%s', '%s', '%s' ], [ '%d' ] ); $data['id'] = $existing_id; } else { $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $table, $data, [ '%d', '%d', '%s', '%s', '%s', '%s' ] ); $data['id'] = (int) $wpdb->insert_id; } /** * Fires after a cancellation-feedback row is stored. * * @param int $subscription_id Subscription ID. * @param array $data Stored feedback row. */ do_action( 'subscrpt_cancellation_feedback_recorded', $subscription_id, $data ); wp_send_json_success( [ 'id' => $data['id'] ] ); } /** * AJAX: the customer accepted the retention offer. * * Free owns the request - nonce, ownership, throttle - and asks for an offer * through `subscrpt_cancellation_offer`. Free itself has nothing to give: the * coupon is Pro's, so without a listener this reports no offer rather than * promising a discount that never arrives. * * @return void */ public function claim_offer() { check_ajax_referer( 'subscrpt_cancellation_feedback', 'nonce' ); $subscription_id = isset( $_POST['subscription_id'] ) ? absint( wp_unslash( $_POST['subscription_id'] ) ) : 0; if ( $subscription_id <= 0 ) { wp_send_json_error( [ 'message' => 'invalid_subscription' ] ); } $subs_post = get_post( $subscription_id ); if ( ! $subs_post || 'subscrpt_order' !== $subs_post->post_type ) { wp_send_json_error( [ 'message' => 'invalid_subscription' ] ); } $author_id = (int) $subs_post->post_author; if ( ! current_user_can( 'manage_options' ) && $author_id !== get_current_user_id() ) { wp_send_json_error( [ 'message' => 'forbidden' ] ); } /** * Filters the retention offer handed to a cancelling customer. * * Return an array with a `code` to make the offer; anything falsy means no * offer was issued and the customer continues to the reasons step. * * @param array|null $offer The offer, or null when none is issued. * @param int $subscription_id Subscription ID. * @param int $customer_id Subscription owner. */ $offer = apply_filters( 'subscrpt_cancellation_offer', null, $subscription_id, $author_id ); if ( empty( $offer['code'] ) ) { wp_send_json_error( [ 'message' => 'no_offer' ] ); } // Accepting the offer is a save, and the strongest kind - report it even // if the customer already dismissed the modal once today. delete_transient( self::save_throttle_key( $subscription_id ) ); set_transient( self::save_throttle_key( $subscription_id ), 1, DAY_IN_SECONDS ); $reason_key = isset( $_POST['reason_key'] ) ? sanitize_key( wp_unslash( $_POST['reason_key'] ) ) : ''; $reason_label = ''; foreach ( self::get_reasons() as $reason ) { if ( isset( $reason['key'] ) && (string) $reason['key'] === $reason_key ) { $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : ''; break; } } do_action( 'subscrpt_subscription_saved', $subscription_id, [ 'subscription_id' => $subscription_id, 'customer_id' => $author_id, 'reason_key' => $reason_key, 'reason_label' => $reason_label, 'offer_accepted' => true, 'offer_code' => (string) $offer['code'], ] ); wp_send_json_success( [ 'code' => (string) $offer['code'], 'message' => isset( $offer['message'] ) ? (string) $offer['message'] : '', ] ); } /** * Transient guarding one save report per subscription per day. * * Every way out of the modal counts as a save - Keep subscription, the X, the * overlay, Escape - so without this a customer idly opening and closing it * would mail the store owner each time. * * @param int $subscription_id Subscription post ID. * @return string */ protected static function save_throttle_key( $subscription_id ) { return 'subscrpt_save_reported_' . (int) $subscription_id; } /** * AJAX: the customer backed out of cancelling. * * Records nothing - the reason list is only meaningful for an actual * cancellation - but fires `subscrpt_subscription_saved` so the retention can * be reported. Throttled to once a day per subscription. * * @return void */ public function record_save() { check_ajax_referer( 'subscrpt_cancellation_feedback', 'nonce' ); $subscription_id = isset( $_POST['subscription_id'] ) ? absint( wp_unslash( $_POST['subscription_id'] ) ) : 0; if ( $subscription_id <= 0 ) { wp_send_json_error( [ 'message' => 'invalid_subscription' ] ); } $subs_post = get_post( $subscription_id ); if ( ! $subs_post || 'subscrpt_order' !== $subs_post->post_type ) { wp_send_json_error( [ 'message' => 'invalid_subscription' ] ); } $author_id = (int) $subs_post->post_author; if ( ! current_user_can( 'manage_options' ) && $author_id !== get_current_user_id() ) { wp_send_json_error( [ 'message' => 'forbidden' ] ); } $throttle = self::save_throttle_key( $subscription_id ); if ( get_transient( $throttle ) ) { wp_send_json_success( [ 'throttled' => true ] ); } set_transient( $throttle, 1, DAY_IN_SECONDS ); $reason_key = isset( $_POST['reason_key'] ) ? sanitize_key( wp_unslash( $_POST['reason_key'] ) ) : ''; $reason_label = ''; foreach ( self::get_reasons() as $reason ) { if ( isset( $reason['key'] ) && (string) $reason['key'] === $reason_key ) { $reason_label = isset( $reason['label'] ) ? (string) $reason['label'] : ''; break; } } $data = [ 'subscription_id' => $subscription_id, 'customer_id' => $author_id, 'reason_key' => $reason_key, 'reason_label' => $reason_label, 'offer_accepted' => false, ]; /** * Fires when a customer opens the cancellation modal and backs out. * * Throttled to once a day per subscription, so a listener may treat each * call as a distinct retention event. * * @param int $subscription_id Subscription ID. * @param array $data Save context: the reason that had been * selected (may be empty) and whether a * retention offer was accepted. */ do_action( 'subscrpt_subscription_saved', $subscription_id, $data ); wp_send_json_success( [ 'saved' => true ] ); } /** * Get Settings * * @param string $id Setting ID. */ public static function get_settings( $id = '' ) { $settings = [ 'subscrpt_cancellation_delay' => subscrpt_pro_activated() ? get_option( 'subscrpt_cancellation_delay', '24h' ) : '24h', 'subscrpt_cancellation_feedback_enabled' => get_option( 'subscrpt_cancellation_feedback_enabled', '1' ), 'subscrpt_cancellation_feedback_comment' => get_option( 'subscrpt_cancellation_feedback_comment', '1' ), ]; return ! empty( $id ) ? $settings[ $id ] ?? false : $settings; } /** * Whether the cancellation-feedback prompt is enabled. * * A free feature — works with or without Pro. * * @return bool */ public static function is_feedback_enabled() { return '1' === self::get_settings( 'subscrpt_cancellation_feedback_enabled' ); } /** * Whether the optional comment box is shown in the feedback form. Defaults to on. * * @return bool */ public static function is_feedback_comment_enabled() { return '1' === self::get_settings( 'subscrpt_cancellation_feedback_comment' ); } /** * Built-in default cancellation reasons. * * Used when the store has not saved its own list. Written for a store * selling goods on a subscription — the reasons a customer stops a product * subscription — and without "Other", which get_reasons() adds by itself. * Filterable so Pro and integrations can adjust the defaults. * * @return array */ public static function default_reasons() { $reasons = [ [ 'key' => 'too_expensive', 'label' => __( 'Too expensive', 'subscription' ), ], [ 'key' => 'too_much_product', 'label' => __( 'I have more than I need', 'subscription' ), ], [ 'key' => 'quality_issues', 'label' => __( 'Not happy with the quality', 'subscription' ), ], [ 'key' => 'delivery_issues', 'label' => __( 'Delivery took too long', 'subscription' ), ], [ 'key' => 'found_better_deal', 'label' => __( 'Found a better deal elsewhere', 'subscription' ), ], [ 'key' => 'taking_a_break', 'label' => __( 'Just taking a break', 'subscription' ), ], ]; /** * Filter the built-in default cancellation reasons. * * @param array $reasons List of { key, label } reason entries. */ return apply_filters( 'subscrpt_cancellation_default_reasons', $reasons ); } /** * The store's own reasons: the saved list, or the defaults when none is saved. * * This is what the Reasons editor edits. It is read with or without Pro — * editing reasons is a free feature. An "other" entry saved before "Other" * became automatic is dropped, so it can never show twice. * * @return array */ public static function get_configured_reasons() { $reasons = get_option( 'subscrpt_cancellation_reasons', [] ); if ( empty( $reasons ) || ! is_array( $reasons ) ) { $reasons = self::default_reasons(); } return array_values( array_filter( $reasons, static function ( $reason ) { return is_array( $reason ) && self::OTHER_KEY !== ( $reason['key'] ?? '' ); } ) ); } /** * The reasons customers are offered. * * The store's reasons, then "Other" when the comment box is on — an answer * that is none of the listed reasons needs the comment to say what it is, * so without the box there is no "Other". Everything customer-facing reads * this, including the label snapshot taken when feedback is recorded. * * @return array */ public static function get_reasons() { $reasons = self::get_configured_reasons(); if ( self::is_feedback_comment_enabled() ) { $reasons[] = [ 'key' => self::OTHER_KEY, 'label' => __( 'Other', 'subscription' ), ]; } return $reasons; } /** * Record when a pending cancellation should become final. * * Runs whenever a subscription enters `pe_cancelled` (frontend, admin, or REST). * If the resolved time is already due, the subscription is cancelled immediately. * * @param int $subscription_id Subscription ID. * @return void */ public function schedule_cancellation( $subscription_id ) { $subscription_id = (int) $subscription_id; /** * Filter the timestamp at which a pending cancellation becomes a full cancellation. * * Return a Unix timestamp. A value at or before the current time cancels the * subscription immediately. Defaults to 24 hours from now. * * @param int $cancel_at Unix timestamp for final cancellation. * @param int $subscription_id Subscription ID. */ $cancel_at = (int) apply_filters( 'subscrpt_cancellation_time', time() + DAY_IN_SECONDS, $subscription_id ); if ( $cancel_at <= time() ) { $this->cancel( $subscription_id ); return; } update_post_meta( $subscription_id, self::CANCEL_AT_META, $cancel_at ); } /** * Hourly sweep: finalise any pending cancellations whose time has come. * * Picks up subscriptions whose `_subscrpt_cancel_at` is due, plus legacy * `pe_cancelled` subscriptions (created before this meta existed) whose billing * period has ended. * * @return void */ public function process_due_cancellations() { $subscriptions = get_posts( [ 'post_type' => 'subscrpt_order', 'post_status' => [ 'pe_cancelled' ], 'fields' => 'ids', 'numberposts' => -1, 'meta_query' => [ 'relation' => 'OR', [ 'key' => self::CANCEL_AT_META, 'value' => time(), 'compare' => '<=', 'type' => 'NUMERIC', ], [ 'relation' => 'AND', [ 'key' => self::CANCEL_AT_META, 'compare' => 'NOT EXISTS', ], [ 'key' => '_subscrpt_next_date', 'value' => time(), 'compare' => '<=', 'type' => 'NUMERIC', ], ], ], ] ); if ( empty( $subscriptions ) ) { return; } // Ensure the mailer is ready so the cancellation email can be sent. if ( function_exists( 'WC' ) && WC()->mailer() ) { foreach ( $subscriptions as $subscription_id ) { $this->cancel( (int) $subscription_id ); } } } /** * Finalise the cancellation of a single subscription. * * Guards against subscriptions that are no longer pending (e.g. reactivated). * * @param int $subscription_id Subscription ID. * @return void */ public function cancel( $subscription_id ) { $subscription_id = (int) $subscription_id; if ( 'pe_cancelled' === get_post_status( $subscription_id ) ) { Action::status( 'cancelled', $subscription_id ); } delete_post_meta( $subscription_id, self::CANCEL_AT_META ); } /** * Drop a scheduled cancellation when a subscription is reactivated. * * @param int $subscription_id Subscription ID. * @return void */ public function clear_scheduled_cancellation( $subscription_id ) { delete_post_meta( (int) $subscription_id, self::CANCEL_AT_META ); } /** * Show a notice on the subscription details page when a cancellation is pending. * * Uses `_subscrpt_cancel_at` (the resolved final-cancellation time), falling back * to the next renewal date for legacy subscriptions. * * @param int $subscription_id Subscription ID. * @return void */ public function display_pending_cancellation_notice( $subscription_id ) { if ( 'pe_cancelled' !== get_post_status( $subscription_id ) ) { return; } $cancel_at = (int) get_post_meta( $subscription_id, self::CANCEL_AT_META, true ); if ( ! $cancel_at ) { $cancel_at = (int) get_post_meta( $subscription_id, '_subscrpt_next_date', true ); } wp_enqueue_style( 'subscrpt_cancellation_css', SUBSCRPT_ASSETS . '/css/cancellation.css', [], SUBSCRPT_VERSION ); ?>

' . esc_html( $effective ) . '' ); } else { 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' ); } ?>