%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; } }