- 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 <noreply@anthropic.com>
390 lines
14 KiB
PHP
390 lines
14 KiB
PHP
<?php
|
||
/**
|
||
* Plugin Name: WooCleanup – Inactive Account Manager
|
||
* Description: Identifies inactive WooCommerce customers (no purchase in a configurable period),
|
||
* sends a configurable notice email, then deletes the account after a grace period.
|
||
* Admins can review all flagged accounts from the WooCommerce menu.
|
||
* Version: 1.0.0
|
||
* Author: WooCleanup
|
||
* License: GPL-2.0-or-later
|
||
* Text Domain: woo-cleanup
|
||
* Requires at least: 5.8
|
||
* Requires PHP: 7.4
|
||
* WC requires at least: 6.0
|
||
* WC tested up to: 8.9
|
||
*/
|
||
|
||
defined( 'ABSPATH' ) || exit;
|
||
|
||
define( 'WOO_CLEANUP_VERSION', '1.0.0' );
|
||
define( 'WOO_CLEANUP_FILE', __FILE__ );
|
||
define( 'WOO_CLEANUP_DIR', plugin_dir_path( __FILE__ ) );
|
||
|
||
// Activation / deactivation hooks must be registered at file load time.
|
||
register_activation_hook( __FILE__, [ 'WooCleanup', 'activate' ] );
|
||
register_deactivation_hook( __FILE__, [ 'WooCleanup', 'deactivate' ] );
|
||
|
||
/**
|
||
* Boot after all plugins are loaded so WooCommerce is available.
|
||
*/
|
||
add_action( 'plugins_loaded', static function () {
|
||
if ( ! class_exists( 'WooCommerce' ) ) {
|
||
add_action( 'admin_notices', static function () {
|
||
printf(
|
||
'<div class="notice notice-error"><p>%s</p></div>',
|
||
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(
|
||
'<!DOCTYPE html><html><body style="font-family:Arial,sans-serif;color:#333;'
|
||
. 'max-width:600px;margin:0 auto;padding:24px;line-height:1.6">%s</body></html>',
|
||
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;
|
||
}
|
||
}
|