From 0f2654111390b8aeaf071572382390ed9970dda3 Mon Sep 17 00:00:00 2001 From: Malin Date: Thu, 5 Mar 2026 17:47:49 +0100 Subject: [PATCH] feat: initial release of WooCleanup inactive account manager - Daily WP-Cron scans all customer-role accounts - Flags users with no completed/processing order in configurable period (default 18 months) - Sends configurable HTML notice email with placeholder support - Deletes account after configurable grace period (default 7 days) - Admin UI under WooCommerce menu: flagged account list with tabs, manual run/send/delete actions, countdown to deletion - Settings page: inactivity period, grace period, email subject/body - HPOS compatible; never touches admin accounts Co-Authored-By: Claude Sonnet 4.6 --- includes/class-admin.php | 592 +++++++++++++++++++++++++++++++++++++++ woo-cleanup.php | 389 +++++++++++++++++++++++++ 2 files changed, 981 insertions(+) create mode 100644 includes/class-admin.php create mode 100644 woo-cleanup.php diff --git a/includes/class-admin.php b/includes/class-admin.php new file mode 100644 index 0000000..cf7a637 --- /dev/null +++ b/includes/class-admin.php @@ -0,0 +1,592 @@ +plugin = $plugin; + + add_action( 'admin_menu', [ $this, 'add_menu' ] ); + add_action( 'admin_post_woo_cleanup_save_settings', [ $this, 'save_settings' ] ); + add_action( 'admin_post_woo_cleanup_manual_run', [ $this, 'handle_manual_run' ] ); + add_action( 'admin_post_woo_cleanup_send_notice', [ $this, 'handle_send_notice' ] ); + add_action( 'admin_post_woo_cleanup_delete_user', [ $this, 'handle_delete_user' ] ); + add_action( 'admin_notices', [ $this, 'show_admin_notices' ] ); + } + + // ========================================================================= + // Menu Registration + // ========================================================================= + + public function add_menu(): void { + add_submenu_page( + 'woocommerce', + __( 'Account Cleanup', 'woo-cleanup' ), + __( 'Account Cleanup', 'woo-cleanup' ), + 'manage_woocommerce', + 'woo-cleanup', + [ $this, 'render_accounts_page' ] + ); + + add_submenu_page( + 'woocommerce', + __( 'Cleanup Settings', 'woo-cleanup' ), + __( 'Cleanup Settings', 'woo-cleanup' ), + 'manage_woocommerce', + 'woo-cleanup-settings', + [ $this, 'render_settings_page' ] + ); + } + + // ========================================================================= + // Accounts List Page + // ========================================================================= + + public function render_accounts_page(): void { + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_die( esc_html__( 'You do not have permission to view this page.', 'woo-cleanup' ) ); + } + + $options = $this->plugin->get_options(); + $grace_days = absint( $options['grace_days'] ); + $active_tab = isset( $_GET['tab'] ) && $_GET['tab'] === 'notice_sent' + ? 'notice_sent' + : 'pending_notice'; + + $pending_users = WooCleanup::get_flagged_users( 'pending_notice' ); + $noticed_users = WooCleanup::get_flagged_users( 'notice_sent' ); + $last_run = get_option( 'woo_cleanup_last_run' ); + + // Cron next run + $next_run = wp_next_scheduled( WooCleanup::CRON_HOOK ); + + $users_to_show = $active_tab === 'notice_sent' ? $noticed_users : $pending_users; + ?> +
+

+ + output_admin_styles(); ?> + + +
+
+ + +
+
+ + +
+
+ + + + + + +  |  + +
+ +
+ + + +
+
+ + +
+

+ + + + . +

+
+ + + + + + +
+ +

+ +

+ +
+ + + + + + + + + + + + + + + + + + + + + ID; + $inactive_since = (int) get_user_meta( $user_id, WooCleanup::META_INACTIVE_SINCE, true ); + $notice_ts = (int) get_user_meta( $user_id, WooCleanup::META_NOTICE_SENT, true ); + $delete_ts = $notice_ts + ( $grace_days * DAY_IN_SECONDS ); + $days_left = max( 0, (int) ceil( ( $delete_ts - time() ) / DAY_IN_SECONDS ) ); + $last_order = WooCleanup::get_last_order_date( $user_id ); + $edit_url = get_edit_user_link( $user_id ); + $row_class = ( $active_tab === 'notice_sent' && $days_left <= 2 ) ? 'woo-cleanup-row--urgent' : ''; + ?> + + + + + + + + + + + + + + + + + + +
+ + display_name ); ?> + +
# +
user_email ); ?>user_registered ) ) ); ?>' . esc_html__( 'Never', 'woo-cleanup' ) . ''; ?> + + + + + + + + +
+ + + + +
+ + + +
+ + + + +
+
+ + +
+ plugin->get_options(); + ?> +
+

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + + + + +
{site_name}
{username}
{email}
{deletion_date}
{grace_days}
+ +

+ +   + + + +

+
+
+ ! empty( $raw['enabled'] ) ? 1 : 0, + 'inactivity_months' => max( 1, absint( $raw['inactivity_months'] ?? 18 ) ), + 'grace_days' => max( 1, absint( $raw['grace_days'] ?? 7 ) ), + 'email_subject' => sanitize_text_field( $raw['email_subject'] ?? '' ), + 'email_body' => wp_kses_post( $raw['email_body'] ?? '' ), + ]; + + update_option( 'woo_cleanup_options', $options ); + + wp_safe_redirect( add_query_arg( + [ 'page' => 'woo-cleanup-settings', 'message' => 'saved' ], + admin_url( 'admin.php' ) + ) ); + exit; + } + + public function handle_manual_run(): void { + if ( ! current_user_can( 'manage_woocommerce' ) + || ! check_admin_referer( 'woo_cleanup_manual_run' ) ) { + wp_die( esc_html__( 'Security check failed.', 'woo-cleanup' ) ); + } + + $this->plugin->run_cleanup(); + + wp_safe_redirect( add_query_arg( + [ 'page' => 'woo-cleanup', 'message' => 'ran' ], + admin_url( 'admin.php' ) + ) ); + exit; + } + + public function handle_send_notice(): void { + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_die( esc_html__( 'Security check failed.', 'woo-cleanup' ) ); + } + + $user_id = absint( $_POST['user_id'] ?? 0 ); + if ( ! $user_id || ! check_admin_referer( 'woo_cleanup_send_notice_' . $user_id ) ) { + wp_die( esc_html__( 'Security check failed.', 'woo-cleanup' ) ); + } + + $options = $this->plugin->get_options(); + $this->plugin->send_notice( $user_id, absint( $options['grace_days'] ), $options ); + update_user_meta( $user_id, WooCleanup::META_NOTICE_SENT, time() ); + + wp_safe_redirect( add_query_arg( + [ 'page' => 'woo-cleanup', 'message' => 'noticed', 'uid' => $user_id ], + admin_url( 'admin.php' ) + ) ); + exit; + } + + public function handle_delete_user(): void { + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_die( esc_html__( 'Security check failed.', 'woo-cleanup' ) ); + } + + $user_id = absint( $_POST['user_id'] ?? 0 ); + if ( ! $user_id || ! check_admin_referer( 'woo_cleanup_delete_user_' . $user_id ) ) { + wp_die( esc_html__( 'Security check failed.', 'woo-cleanup' ) ); + } + + // Double-check: never delete admins via this form. + $user = get_userdata( $user_id ); + if ( $user && in_array( 'administrator', (array) $user->roles, true ) ) { + wp_die( esc_html__( 'Cannot delete administrator accounts.', 'woo-cleanup' ) ); + } + + $this->plugin->delete_customer( $user_id ); + + wp_safe_redirect( add_query_arg( + [ 'page' => 'woo-cleanup', 'message' => 'deleted' ], + admin_url( 'admin.php' ) + ) ); + exit; + } + + // ========================================================================= + // Admin Notices + // ========================================================================= + + public function show_admin_notices(): void { + $screen = get_current_screen(); + if ( ! $screen || strpos( $screen->id, 'woo-cleanup' ) === false ) { + return; + } + + $message = $_GET['message'] ?? ''; + $uid = absint( $_GET['uid'] ?? 0 ); + + switch ( $message ) { + case 'saved': + echo '

' + . esc_html__( 'Settings saved.', 'woo-cleanup' ) + . '

'; + break; + case 'ran': + echo '

' + . esc_html__( 'Cleanup routine completed. The account list has been updated.', 'woo-cleanup' ) + . '

'; + break; + case 'noticed': + $user = $uid ? get_userdata( $uid ) : null; + echo '

' + . esc_html( sprintf( + __( 'Notice email sent to %s.', 'woo-cleanup' ), + $user ? $user->display_name : "user #{$uid}" + ) ) + . '

'; + break; + case 'deleted': + echo '

' + . esc_html__( 'Account deleted.', 'woo-cleanup' ) + . '

'; + break; + } + } + + // ========================================================================= + // Inline Styles (small footprint; avoids an extra HTTP request) + // ========================================================================= + + private function output_admin_styles(): void { + ?> + +

%s

', + esc_html__( 'WooCleanup requires WooCommerce to be installed and active.', 'woo-cleanup' ) + ); + } ); + return; + } + + require_once WOO_CLEANUP_DIR . 'includes/class-admin.php'; + + WooCleanup::get_instance(); +} ); + +// Declare HPOS compatibility. +add_action( 'before_woocommerce_init', static function () { + if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) { + \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( + 'custom_order_tables', + __FILE__, + true + ); + } +} ); + +// ============================================================================ + +/** + * Core plugin class. + * + * Responsibilities: + * – Scheduling / de-scheduling the daily cron. + * – Running the cleanup routine (cron callback). + * – Sending notice emails. + * – Deleting inactive accounts. + * – Providing option helpers consumed by the admin UI. + */ +class WooCleanup { + + // ── User meta keys ────────────────────────────────────────────────────── + const META_INACTIVE_SINCE = '_woo_cleanup_inactive_since'; + const META_NOTICE_SENT = '_woo_cleanup_notice_sent'; + + // ── Cron hook ─────────────────────────────────────────────────────────── + const CRON_HOOK = 'woo_cleanup_daily_cron'; + + // ── Singleton ─────────────────────────────────────────────────────────── + private static ?self $instance = null; + + public static function get_instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + return self::$instance; + } + + private function __construct() { + add_action( self::CRON_HOOK, [ $this, 'run_cleanup' ] ); + + if ( is_admin() ) { + new WooCleanup_Admin( $this ); + } + } + + // ========================================================================= + // Activation / Deactivation + // ========================================================================= + + public static function activate(): void { + if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { + wp_schedule_event( time(), 'daily', self::CRON_HOOK ); + } + } + + public static function deactivate(): void { + wp_clear_scheduled_hook( self::CRON_HOOK ); + } + + // ========================================================================= + // Cron: Main Cleanup Routine + // ========================================================================= + + /** + * Called once daily by WP-Cron. + * Iterates every customer and applies the cleanup workflow. + */ + public function run_cleanup(): void { + $options = $this->get_options(); + + if ( empty( $options['enabled'] ) ) { + return; + } + + $inactivity_months = absint( $options['inactivity_months'] ); + $grace_days = absint( $options['grace_days'] ); + $cutoff = strtotime( "-{$inactivity_months} months" ); + + $customer_ids = get_users( [ + 'role' => 'customer', + 'fields' => 'ID', + ] ); + + foreach ( $customer_ids as $user_id ) { + $this->process_customer( (int) $user_id, $cutoff, $grace_days, $options ); + } + + update_option( 'woo_cleanup_last_run', time(), false ); + } + + // ========================================================================= + // Per-Customer Workflow + // ========================================================================= + + /** + * Evaluate a single customer and take the appropriate action. + * + * States: + * 1. Active (has a recent order) → clear any pending flags, do nothing. + * 2. Inactive, no notice sent yet → send notice, record timestamp. + * 3. Inactive, notice sent, grace valid → wait. + * 4. Inactive, grace period expired → delete account. + * + * @param int $user_id User ID. + * @param int $cutoff Unix timestamp; orders BEFORE this = inactive. + * @param int $grace_days Days between notice and deletion. + * @param array $options Plugin options. + */ + public function process_customer( int $user_id, int $cutoff, int $grace_days, array $options ): void { + if ( $this->has_recent_order( $user_id, $cutoff ) ) { + // User is active – clear any pending cleanup flags. + delete_user_meta( $user_id, self::META_INACTIVE_SINCE ); + delete_user_meta( $user_id, self::META_NOTICE_SENT ); + return; + } + + // Record when we first noticed inactivity (idempotent). + if ( ! get_user_meta( $user_id, self::META_INACTIVE_SINCE, true ) ) { + update_user_meta( $user_id, self::META_INACTIVE_SINCE, time() ); + } + + $notice_sent = (int) get_user_meta( $user_id, self::META_NOTICE_SENT, true ); + + if ( ! $notice_sent ) { + // No notice sent yet – send it now. + if ( $this->send_notice( $user_id, $grace_days, $options ) ) { + update_user_meta( $user_id, self::META_NOTICE_SENT, time() ); + } + } else { + // Notice already sent – check if the grace period has expired. + $delete_at = $notice_sent + ( $grace_days * DAY_IN_SECONDS ); + if ( time() >= $delete_at ) { + $this->delete_customer( $user_id ); + } + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Returns true if the user has at least one order (completed or processing) + * created after $cutoff. + */ + public function has_recent_order( int $user_id, int $cutoff ): bool { + $orders = wc_get_orders( [ + 'customer_id' => $user_id, + 'limit' => 1, + 'return' => 'ids', + 'status' => [ 'wc-completed', 'wc-processing', 'wc-on-hold' ], + 'date_created' => '>' . date( 'Y-m-d', $cutoff ), + ] ); + + return ! empty( $orders ); + } + + /** + * Send the inactivity notice email to a user. + * + * @return bool True on successful hand-off to wp_mail. + */ + public function send_notice( int $user_id, int $grace_days, array $options ): bool { + $user = get_userdata( $user_id ); + if ( ! $user ) { + return false; + } + + $deletion_date = date_i18n( + get_option( 'date_format' ), + time() + ( $grace_days * DAY_IN_SECONDS ) + ); + + $vars = [ + '{site_name}' => get_bloginfo( 'name' ), + '{username}' => $user->display_name, + '{email}' => $user->user_email, + '{deletion_date}' => $deletion_date, + '{grace_days}' => (string) $grace_days, + ]; + + $subject = strtr( $options['email_subject'], $vars ); + $body = strtr( $options['email_body'], $vars ); + + // Wrap plain-text body in a minimal HTML shell so mail clients render + // line-breaks and the Content-Type header is honoured. + $html = sprintf( + '%s', + wpautop( wp_kses_post( $body ) ) + ); + + return wp_mail( + $user->user_email, + $subject, + $html, + [ 'Content-Type: text/html; charset=UTF-8' ] + ); + } + + /** + * Permanently delete a customer account. + * Orders are intentionally left intact (required for financial records). + */ + public function delete_customer( int $user_id ): void { + if ( ! function_exists( 'wp_delete_user' ) ) { + require_once ABSPATH . 'wp-admin/includes/user.php'; + } + + /** + * Fires just before WooCleanup deletes an inactive customer. + * + * @param int $user_id The user ID about to be deleted. + */ + do_action( 'woo_cleanup_before_delete_user', $user_id ); + + wp_delete_user( $user_id ); + + /** + * Fires after WooCleanup has deleted an inactive customer. + * + * @param int $user_id The deleted user ID (no longer valid in DB). + */ + do_action( 'woo_cleanup_after_delete_user', $user_id ); + } + + // ========================================================================= + // Options + // ========================================================================= + + public function get_options(): array { + return wp_parse_args( + (array) get_option( 'woo_cleanup_options', [] ), + $this->get_defaults() + ); + } + + public function get_defaults(): array { + return [ + 'enabled' => 1, + 'inactivity_months' => 18, + 'grace_days' => 7, + 'email_subject' => 'Important: Your account at {site_name} is scheduled for deletion', + 'email_body' => $this->get_default_email_body(), + ]; + } + + private function get_default_email_body(): string { + return "Dear {username},\n\n" + . "We noticed that you haven't made a purchase at {site_name} in quite some time.\n\n" + . "As part of our regular account maintenance, inactive accounts are periodically removed. " + . "Your account is scheduled for deletion on {deletion_date} ({grace_days} days from today).\n\n" + . "To keep your account, simply log in and place an order before that date. " + . "If you no longer need your account, no action is required.\n\n" + . "If you have any questions, please contact our support team.\n\n" + . "Best regards,\nThe {site_name} Team"; + } + + // ========================================================================= + // Static Queries (consumed by admin page) + // ========================================================================= + + /** + * Return WP_User objects for all flagged customers. + * + * @param string $status 'pending_notice' | 'notice_sent' | 'all' + * @return WP_User[] + */ + public static function get_flagged_users( string $status = 'all' ): array { + switch ( $status ) { + case 'pending_notice': + $meta_query = [ + 'relation' => 'AND', + [ + 'key' => self::META_INACTIVE_SINCE, + 'compare' => 'EXISTS', + ], + [ + 'key' => self::META_NOTICE_SENT, + 'compare' => 'NOT EXISTS', + ], + ]; + break; + + case 'notice_sent': + $meta_query = [ + [ + 'key' => self::META_NOTICE_SENT, + 'compare' => 'EXISTS', + ], + ]; + break; + + default: // 'all' + $meta_query = [ + 'relation' => 'OR', + [ + 'key' => self::META_INACTIVE_SINCE, + 'compare' => 'EXISTS', + ], + [ + 'key' => self::META_NOTICE_SENT, + 'compare' => 'EXISTS', + ], + ]; + break; + } + + return get_users( [ + 'role' => 'customer', + 'meta_query' => $meta_query, + 'orderby' => 'registered', + 'order' => 'ASC', + ] ); + } + + /** + * Return the last order date for a user, or null if they have no orders. + */ + public static function get_last_order_date( int $user_id ): ?string { + $orders = wc_get_orders( [ + 'customer_id' => $user_id, + 'limit' => 1, + 'orderby' => 'date', + 'order' => 'DESC', + 'return' => 'objects', + ] ); + + if ( empty( $orders ) ) { + return null; + } + + /** @var WC_Order $order */ + $order = reset( $orders ); + $date = $order->get_date_created(); + + return $date ? $date->date_i18n( get_option( 'date_format' ) ) : null; + } +}