-
Notifications
You must be signed in to change notification settings - Fork 49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(wc): duplicate orders admin notice #3555
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
361a355
feat(wc): duplicate orders admin notice
adekbadek edeef99
feat: handle HPOS
adekbadek 4353272
feat: handle order series dismissal
adekbadek a7b9eeb
feat: better UX with a details element
adekbadek 588d66e
feat: iterate orders instead of using a direct DB query
adekbadek 3468af6
chore: unify wording
adekbadek 224b24e
fix: handle the order meta
adekbadek 0fa853e
feat: CLI tool
adekbadek 97bb2a1
feat: process orders in batches
adekbadek 520f49c
feat: tweak
adekbadek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
250 changes: 250 additions & 0 deletions
250
includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is it detect or upsert?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Without
--save
, it's only detection. With it, it's upsertion.