Skip to content

Commit

Permalink
feat(wc): duplicate orders admin notice (#3555)
Browse files Browse the repository at this point in the history
* feat(wc): duplicate orders admin notice

* feat: handle HPOS

* feat: handle order series dismissal

* feat: better UX with a details element

* feat: iterate orders instead of using a direct DB query

* chore: unify wording

* fix: handle the order meta

* feat: CLI tool

* feat: process orders in batches

* feat: tweak
  • Loading branch information
adekbadek authored and chickenn00dle committed Dec 12, 2024
1 parent eaf5e18 commit b6b7c85
Show file tree
Hide file tree
Showing 2 changed files with 251 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public static function init() {
include_once __DIR__ . '/class-woocommerce-cover-fees.php';
include_once __DIR__ . '/class-woocommerce-order-utm.php';
include_once __DIR__ . '/class-woocommerce-products.php';
include_once __DIR__ . '/class-woocommerce-duplicate-orders.php';

\add_action( 'admin_init', [ __CLASS__, 'disable_woocommerce_setup' ] );
\add_filter( 'option_woocommerce_subscriptions_allow_switching', [ __CLASS__, 'force_allow_subscription_switching' ], 10, 2 );
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
<?php
/**
* Adds an admin notice when possibly duplicated orders are detected.
*
* @package Newspack
*/

namespace Newspack;

defined( 'ABSPATH' ) || exit;

/**
* Adds an admin notice when possibly duplicated orders are detected.
*/
class WooCommerce_Duplicate_Orders {
const CRON_HOOK_NAME = 'newspack_wc_check_order_duplicates';
const ADMIN_NOTICE_TRANSIENT_NAME = 'newspack_wc_check_order_duplicates_admin_notice';
const DUPLICATED_ORDERS_OPTION_NAME = 'newspack_wc_order_duplicates';
const DISMISSED_DUPLICATES_OPTION_NAME = 'newspack_wc_order_duplicates_dismissed';

/**
* Initialize.
*
* @codeCoverageIgnore
*/
public static function init(): void {
if ( ! wp_next_scheduled( self::CRON_HOOK_NAME ) ) {
wp_schedule_event( time(), 'daily', self::CRON_HOOK_NAME );
}
add_action( self::CRON_HOOK_NAME, [ __CLASS__, 'check_for_order_duplicates' ] );
add_action( 'admin_notices', [ __CLASS__, 'display_admin_notice' ] );

if ( defined( 'WP_CLI' ) && WP_CLI ) {
\WP_CLI::add_command( 'newspack detect-order-duplicates', [ __CLASS__, 'cli_upsert_order_duplicates' ] );
}
}

/**
* Detect duplicate orders.
* Duplicates will be detected if it the same amount, same day, from the same customer.
*
* @param number $cutoff_time The cutoff time in the past (how many seconds ago).
* @param number $current_page Current page of results.
* @param array $results Results to be merged with new results.
*/
public static function get_order_duplicates( $cutoff_time, $current_page = 0, $results = [] ): array {
$per_page = 100;
$order_result = wc_get_orders(
[
'paginate' => true,
'limit' => $per_page,
'status' => [ 'wc-completed' ],
'offset' => $current_page * $per_page,
'date_completed' => '>' . ( time() - $cutoff_time ),
]
);

if ( defined( 'WP_CLI' ) && WP_CLI && $order_result->max_num_pages > 0 ) {
\WP_CLI::line( sprintf( 'Processing page %d/%d of orders.', $current_page + 1, $order_result->max_num_pages ) );
}

$orders = $order_result->orders;
$order_duplicates = [];

foreach ( $orders as $order ) {
$email = $order->get_billing_email();
$amount = $order->get_total();
$date = $order->get_date_created()->date( 'Y-m-d' );

if ( \wcs_order_contains_renewal( $order ) || \wcs_order_contains_resubscribe( $order ) ) {
continue;
}

if ( ! isset( $order_duplicates[ $email ] ) ) {
$order_duplicates[ $email ] = [];
}

if ( ! isset( $order_duplicates[ $email ][ $amount ] ) ) {
$order_duplicates[ $email ][ $amount ] = [];
}

if ( ! isset( $order_duplicates[ $email ][ $amount ][ $date ] ) ) {
$order_duplicates[ $email ][ $amount ][ $date ] = [];
}

$order_duplicates[ $email ][ $amount ][ $date ][] = $order->get_id();
}

foreach ( $order_duplicates as $email => $amounts ) {
foreach ( $amounts as $amount => $dates ) {
foreach ( $dates as $date => $order_ids ) {
if ( count( $order_ids ) > 1 ) {
sort( $order_ids );
$ids = implode( ',', $order_ids );
$results[ $ids ] = [
'email' => $email,
'amount' => $amount,
'date' => $date,
'ids' => $ids,
];
}
}
}
}

if ( $order_result->total > 0 ) {
$current_page++;
return self::get_order_duplicates( $cutoff_time, $current_page, $results );
}

return $results;
}

/**
* Check for duplicate orders and save the result in an option.
*
* @param number $cutoff_time The cutoff time in the past (how many seconds ago).
* @param bool $save Whether to save the result as the option.
* @param bool $upsert Whether to upsert the option (merge with existing).
*/
public static function check_for_order_duplicates( $cutoff_time = DAY_IN_SECONDS, $save = false, $upsert = true ): array {
$order_duplicates = self::get_order_duplicates( $cutoff_time );
if ( empty( $order_duplicates ) ) {
return [];
}
if ( $save ) {
if ( $upsert ) {
$existing_order_duplicates = get_option( self::DUPLICATED_ORDERS_OPTION_NAME, [] );
foreach ( $existing_order_duplicates as $key => $value ) {
if ( isset( $order_duplicates[ $key ] ) ) {
continue;
}
$order_duplicates[ $key ] = $value;
}
}
update_option( self::DUPLICATED_ORDERS_OPTION_NAME, $order_duplicates );
}
return $order_duplicates;
}

/**
* Display an admin notice if duplicate orders are found.
*/
public static function display_admin_notice(): void {
if ( ! function_exists( 'wc_price' ) ) {
return;
}
$existing_order_duplicates = get_option( self::DUPLICATED_ORDERS_OPTION_NAME, [] );
if ( empty( $existing_order_duplicates ) ) {
return;
}
$dismissed_duplicates = get_option( self::DISMISSED_DUPLICATES_OPTION_NAME, [] );
?>
<div class="notice notice-info is-dismissible">
<!-- Admin notice added by newspack-plugin -->
<details>
<summary style="margin: 0.6em 0; cursor: pointer;">
<?php echo esc_html__( 'There are some potentially duplicate transactions to review. Some of these might be intentional. Click this message to display the list of possible duplicates.', 'newspack-plugin' ); ?>
</summary>
<ul>
<?php foreach ( $existing_order_duplicates as $order_duplicates ) : ?>
<?php
if ( in_array( $order_duplicates['ids'], $dismissed_duplicates ) ) {
continue;}
?>
<li style="display: flex; align-items: center;">
<p style="margin: 0;">

<?php
ob_start();
?>
<a href="<?php echo esc_url( admin_url( 'edit.php?s=' . urlencode( $order_duplicates['email'] ) . '&post_type=shop_order' ) ); ?>"><?php echo esc_html( $order_duplicates['email'] ); ?></a>
<?php
$customer_email = ob_get_clean();

ob_start();
$order_ids = explode( ',', $order_duplicates['ids'] );
foreach ( $order_ids as $index => $order_id ) :
$order_url = admin_url( 'post.php?post=' . intval( $order_id ) . '&action=edit' );
?>
<a href="<?php echo esc_url( $order_url ); ?>"><?php echo esc_html( $order_id ); ?></a><?php echo ( $index < count( $order_ids ) - 1 ) ? ', ' : ''; ?>
<?php
endforeach;
$order_list = ob_get_clean();

printf(
/* translators: 1: customer email, 2: order amount, 3: orders date, 4: order IDs */
wp_kses_post( __( 'Customer %1$s made multiple orders of %2$s on %3$s. Orders: %4$s.', 'newspack-plugin' ) ),
wp_kses_post( $customer_email ),
wp_kses_post( \wc_price( $order_duplicates['amount'] ) ),
esc_html( date_i18n( get_option( 'date_format' ), strtotime( $order_duplicates['date'] ) ) ),
wp_kses_post( trim( $order_list ) )
);

$order_duplicates_id = implode( '-', $order_ids );
?>
</p>
<form method="post" style="display:inline; margin-left: 8px;">
<input type="hidden" name="dismiss_order_ids" value="<?php echo esc_attr( $order_duplicates['ids'] ); ?>">
<?php submit_button( __( 'Dismiss', 'newspack-plugin' ), 'small', 'dismiss_order', false, [ 'id' => 'dismiss_order_duplicates_' . $order_duplicates_id ] ); ?>
</form>
</li>
<?php endforeach; ?>
</ul>
</details>
</div>
<?php
if ( isset( $_POST['dismiss_order'] ) && isset( $_POST['dismiss_order_ids'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
$dismissed_duplicates[] = $_POST['dismiss_order_ids']; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
update_option( self::DISMISSED_DUPLICATES_OPTION_NAME, $dismissed_duplicates );
// Refresh the page to reflect changes.
wp_safe_redirect( isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : admin_url() );
exit;
}
}

/**
* CLI handler to search for duplicates and optionally store this info to be displayed in the admin panel.
*
* ## OPTIONS
*
* [--cutoff_time=<time-string>]
* : The cutoff time in the past (e.g. "2 months").
*
* [--save]
* : Whether to save the results for display in the admin panel.
*
* ## EXAMPLES
*
* wp newspack detect-order-duplicates --cutoff_time='2 months' --save
*
* @param array $args Positional args.
* @param array $assoc_args Associative args.
*/
public static function cli_upsert_order_duplicates( $args, $assoc_args ) {
$cutoff_time_str = isset( $assoc_args['cutoff_time'] ) ? $assoc_args['cutoff_time'] : '1 month';
$cutoff_time = strtotime( $cutoff_time_str ) - time();
$save_as_option = isset( $assoc_args['save'] ) ? $assoc_args['save'] : false;

$duplicates = self::check_for_order_duplicates( $cutoff_time, $save_as_option, false );

if ( empty( $duplicates ) ) {
\WP_CLI::success( 'No duplicate orders found.' );
} else {
\WP_CLI::success( sprintf( '%d duplicate order series found.', count( $duplicates ) ) );
}
}
}

WooCommerce_Duplicate_Orders::init();

0 comments on commit b6b7c85

Please sign in to comment.