Files
WooCleanup/woo-cleanup.php
Malin 0f26541113 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 <noreply@anthropic.com>
2026-03-05 17:47:49 +01:00

390 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}