diff --git a/assets/css/cancellation.css b/assets/css/cancellation.css new file mode 100644 index 0000000..ef67433 --- /dev/null +++ b/assets/css/cancellation.css @@ -0,0 +1,39 @@ +.subscrpt-pending-cancel-notice { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0 0 24px; + padding: 14px 16px; + border: 1px dashed #fca5a5; + border-radius: 10px; + background: linear-gradient(180deg, #fffafa 0%, #fef2f2 100%); + box-shadow: 0 1px 2px rgba(120, 0, 0, 0.06); + color: #3f3f46; + font-size: 0.95em; + line-height: 1.5; +} + +.subscrpt-pending-cancel-notice__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 34px; + height: 34px; + border-radius: 50%; + background: #fee2e2; + color: #b91c1c; +} + +.subscrpt-pending-cancel-notice__body { + flex: 1 1 auto; + min-width: 0; +} + +.subscrpt-pending-cancel-notice__text { + margin: 0; +} + +.subscrpt-pending-cancel-notice__text strong { + color: #18181b; +} diff --git a/includes/Admin/ProSettingsFields.php b/includes/Admin/ProSettingsFields.php index 53bb920..68b1d81 100644 --- a/includes/Admin/ProSettingsFields.php +++ b/includes/Admin/ProSettingsFields.php @@ -105,6 +105,22 @@ private function pro_core_fields() { 'checked' => '1' === get_option( 'subscrpt_early_renew', '1' ), ], ], + [ + 'type' => 'select', + 'group' => 'main', + 'priority' => 9, + 'field_data' => [ + 'id' => 'subscrpt_cancellation_delay', + 'title' => __( 'Cancellation Timing', 'subscription' ), + 'description' => __( 'When a subscription is cancelled, choose when it actually ends.', 'subscription' ), + 'options' => [ + '24h' => __( 'After 24 hours', 'subscription' ), + 'instant' => __( 'Immediately', 'subscription' ), + 'period' => __( 'At end of billing period (before next renewal)', 'subscription' ), + ], + 'selected' => esc_attr( \SpringDevs\Subscription\Illuminate\Cancellation::get_settings( 'subscrpt_cancellation_delay' ) ), + ], + ], ]; } diff --git a/includes/Illuminate.php b/includes/Illuminate.php index 9e49b4c..19cb126 100644 --- a/includes/Illuminate.php +++ b/includes/Illuminate.php @@ -5,6 +5,7 @@ use SpringDevs\Subscription\Frontend\Checkout; use SpringDevs\Subscription\Illuminate\AutoRenewal; use SpringDevs\Subscription\Illuminate\Block; +use SpringDevs\Subscription\Illuminate\Cancellation; use SpringDevs\Subscription\Illuminate\Cron; use SpringDevs\Subscription\Illuminate\Email; use SpringDevs\Subscription\Illuminate\Order; @@ -31,6 +32,7 @@ public function __construct() { new RoleManagement(); new Order(); new Cron(); + new Cancellation(); new Stats(); new Post(); new Block(); @@ -75,7 +77,7 @@ public function paypal_initialization() { // Register the PayPal gateway with WooCommerce. if ( $is_paypal_integration_enabled ) { - add_filter( 'woocommerce_payment_gateways', array( $this, 'register_paypal_gateway' ) ); + add_filter( 'woocommerce_payment_gateways', [ $this, 'register_paypal_gateway' ] ); } } diff --git a/includes/Illuminate/Cancellation.php b/includes/Illuminate/Cancellation.php new file mode 100644 index 0000000..74156d9 --- /dev/null +++ b/includes/Illuminate/Cancellation.php @@ -0,0 +1,215 @@ + subscrpt_pro_activated() ? get_option( 'subscrpt_cancellation_delay', '24h' ) : '24h', + ]; + return ! empty( $id ) ? $settings[ $id ] ?? false : $settings; + } + + /** + * 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' ); + } + ?> +

+
+
+ maybe_reschedule_cron(); } @@ -61,35 +61,38 @@ public function hourly_cron_task() { } /** - * Update subscription statuses. + * Expire active subscriptions whose term has ended. + * + * Pending cancellations (`pe_cancelled`) are handled separately by + * {@see Cancellation}, not here. */ public function update_subscription_statusses() { - $args = array( + $args = [ 'post_type' => 'subscrpt_order', - 'post_status' => array( 'active', 'pe_cancelled' ), + 'post_status' => [ 'active' ], 'fields' => 'ids', - 'meta_query' => array( + 'meta_query' => [ 'relation' => 'OR', - array( + [ 'key' => '_subscrpt_next_date', 'value' => time(), 'compare' => '<=', - ), - array( + ], + [ 'relation' => 'AND', - array( + [ 'key' => '_subscrpt_trial', 'value' => null, 'compare' => '!=', - ), - array( + ], + [ 'key' => '_subscrpt_start_date', 'value' => time(), 'compare' => '<=', - ), - ), - ), - ); + ], + ], + ], + ]; $expired_subscriptions = get_posts( $args ); @@ -97,11 +100,7 @@ public function update_subscription_statusses() { // Initialize WooCommerce mailer before processing if ( function_exists( 'WC' ) && WC()->mailer() ) { foreach ( $expired_subscriptions as $subscription ) { - if ( 'pe_cancelled' === get_post_status( $subscription ) ) { - Action::status( 'cancelled', $subscription ); - } else { - Action::status( 'expired', $subscription ); - } + Action::status( 'expired', $subscription ); } } }