From 361a355dda706947fc1e7b7986f3920c71140912 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Mon, 18 Nov 2024 13:11:22 +0100 Subject: [PATCH 01/10] feat(wc): duplicate orders admin notice --- .../class-woocommerce-connection.php | 1 + .../class-woocommerce-duplicate-orders.php | 137 ++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-connection.php b/includes/reader-revenue/woocommerce/class-woocommerce-connection.php index fc1c5835e0..48f1f7ce36 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-connection.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-connection.php @@ -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 ); diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php new file mode 100644 index 0000000000..256ac843de --- /dev/null +++ b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php @@ -0,0 +1,137 @@ +posts} AS p + JOIN {$wpdb->postmeta} AS pm_email + ON p.ID = pm_email.post_id + JOIN {$wpdb->postmeta} AS pm_amount + ON p.ID = pm_amount.post_id + LEFT JOIN {$wpdb->postmeta} AS pm_renewal + ON p.ID = pm_renewal.post_id AND pm_renewal.meta_key = '_subscription_renewal' + WHERE p.post_type = 'shop_order' + AND pm_email.meta_key = '_billing_email' + AND pm_amount.meta_key = '_order_total' + AND CAST(pm_amount.meta_value AS DECIMAL(10,2)) > 0.00 -- Exclude transactions with 0.00 amount + AND pm_renewal.post_id IS NULL + AND EXISTS ( + SELECT 1 + FROM {$wpdb->posts} AS p2 + WHERE p2.post_type = 'shop_order' + AND pm_email.meta_value = (SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = p2.ID AND meta_key = '_billing_email') + AND pm_amount.meta_value = (SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = p2.ID AND meta_key = '_order_total') + AND ABS(TIMESTAMPDIFF(MINUTE, p2.post_date, p.post_date)) <= $series_interval + AND p2.ID != p.ID + ) + GROUP BY pm_email.meta_value, DATE(p.post_date), pm_amount.meta_value + HAVING COUNT(*) > 1 -- Ensures only duplicates are included + "; + return $wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery + } + + /** + * Add an admin notice about the detected order series. + */ + public static function check_for_order_series() { + $order_series = self::detect_order_series(); + if ( empty( $order_series ) ) { + return; + } + update_option( self::DUPLICATED_ORDERS_OPTION_NAME, $order_series ); + } + + /** + * Display an admin notice if duplicate orders are found. + */ + public static function display_admin_notice() { + if ( ! function_exists( 'wc_price' ) ) { + return; + } + $order_series = get_option( self::DUPLICATED_ORDERS_OPTION_NAME, [] ); + ?> +
+ +

+ +

+ +
+ Date: Tue, 19 Nov 2024 15:07:40 +0100 Subject: [PATCH 02/10] feat: handle HPOS --- .../class-woocommerce-duplicate-orders.php | 100 ++++++++++++------ 1 file changed, 68 insertions(+), 32 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php index 256ac843de..38065e16f5 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php @@ -39,36 +39,72 @@ public static function init() { */ public static function detect_order_series( $series_interval = 10 ) { global $wpdb; - $query = " - SELECT - pm_email.meta_value AS email, - DATE(p.post_date) AS date, - pm_amount.meta_value AS amount, - GROUP_CONCAT(p.ID) AS ids - FROM {$wpdb->posts} AS p - JOIN {$wpdb->postmeta} AS pm_email - ON p.ID = pm_email.post_id - JOIN {$wpdb->postmeta} AS pm_amount - ON p.ID = pm_amount.post_id - LEFT JOIN {$wpdb->postmeta} AS pm_renewal - ON p.ID = pm_renewal.post_id AND pm_renewal.meta_key = '_subscription_renewal' - WHERE p.post_type = 'shop_order' - AND pm_email.meta_key = '_billing_email' - AND pm_amount.meta_key = '_order_total' - AND CAST(pm_amount.meta_value AS DECIMAL(10,2)) > 0.00 -- Exclude transactions with 0.00 amount - AND pm_renewal.post_id IS NULL - AND EXISTS ( - SELECT 1 - FROM {$wpdb->posts} AS p2 - WHERE p2.post_type = 'shop_order' - AND pm_email.meta_value = (SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = p2.ID AND meta_key = '_billing_email') - AND pm_amount.meta_value = (SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = p2.ID AND meta_key = '_order_total') - AND ABS(TIMESTAMPDIFF(MINUTE, p2.post_date, p.post_date)) <= $series_interval - AND p2.ID != p.ID - ) - GROUP BY pm_email.meta_value, DATE(p.post_date), pm_amount.meta_value - HAVING COUNT(*) > 1 -- Ensures only duplicates are included - "; + + $is_hpos_enabled = class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); + + if ( $is_hpos_enabled ) { + $query = " + SELECT + o_billing.email AS email, + DATE(o.date_created_gmt) AS date, + o.total_amount AS amount, + GROUP_CONCAT(o.id) AS ids + FROM {$wpdb->prefix}wc_orders AS o + JOIN {$wpdb->prefix}wc_order_addresses AS o_billing + ON o.id = o_billing.order_id AND o_billing.address_type = 'billing' + LEFT JOIN {$wpdb->prefix}wc_orders_meta AS o_meta + ON o.id = o_meta.order_id AND o_meta.meta_key = '_subscription_renewal' + WHERE o_billing.email IS NOT NULL + AND o.total_amount > 0.00 + AND o.status = 'wc-completed' + AND o_meta.order_id IS NULL + AND EXISTS ( + SELECT 1 + FROM {$wpdb->prefix}wc_orders AS o2 + JOIN {$wpdb->prefix}wc_order_addresses AS o2_billing + ON o2.id = o2_billing.order_id AND o2_billing.address_type = 'billing' + WHERE o2_billing.email = o_billing.email + AND o2.total_amount = o.total_amount + AND ABS(TIMESTAMPDIFF(MINUTE, o2.date_created_gmt, o.date_created_gmt)) <= $series_interval + AND o2.id != o.id + ) + GROUP BY o_billing.email, DATE(o.date_created_gmt), o.total_amount + HAVING COUNT(*) > 1 -- Ensures only duplicates are included + "; + } else { + $query = " + SELECT + pm_email.meta_value AS email, + DATE(p.post_date) AS date, + pm_amount.meta_value AS amount, + GROUP_CONCAT(p.ID) AS ids + FROM {$wpdb->posts} AS p + JOIN {$wpdb->postmeta} AS pm_email + ON p.ID = pm_email.post_id + JOIN {$wpdb->postmeta} AS pm_amount + ON p.ID = pm_amount.post_id + LEFT JOIN {$wpdb->postmeta} AS pm_renewal + ON p.ID = pm_renewal.post_id AND pm_renewal.meta_key = '_subscription_renewal' + WHERE p.post_type = 'shop_order' + AND p.post_status = 'wc-completed' + AND pm_email.meta_key = '_billing_email' + AND pm_amount.meta_key = '_order_total' + AND CAST(pm_amount.meta_value AS DECIMAL(10,2)) > 0.00 + AND pm_renewal.post_id IS NULL + AND EXISTS ( + SELECT 1 + FROM {$wpdb->posts} AS p2 + WHERE p2.post_type = 'shop_order' + AND pm_email.meta_value = (SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = p2.ID AND meta_key = '_billing_email') + AND pm_amount.meta_value = (SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = p2.ID AND meta_key = '_order_total') + AND ABS(TIMESTAMPDIFF(MINUTE, p2.post_date, p.post_date)) <= $series_interval + AND p2.ID != p.ID + ) + GROUP BY pm_email.meta_value, DATE(p.post_date), pm_amount.meta_value + HAVING COUNT(*) > 1 -- Ensures only duplicates are included + "; + } + return $wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery } @@ -118,12 +154,12 @@ public static function display_admin_notice() { $order_list = ob_get_clean(); printf( - /* translators: 1: customer email, 2: order amount, 3: order IDs */ + /* 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_series['amount'] ) ), esc_html( date_i18n( get_option( 'date_format' ), strtotime( $order_series['date'] ) ) ), - wp_kses_post( $order_list ) + wp_kses_post( trim( $order_list ) ) ); ?> From 4353272d4a33c6ba09f19fd3075a0f83e9a1c4e3 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Tue, 19 Nov 2024 15:44:48 +0100 Subject: [PATCH 03/10] feat: handle order series dismissal --- .../class-woocommerce-duplicate-orders.php | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php index 38065e16f5..345f2ddbaa 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php @@ -16,13 +16,14 @@ class WooCommerce_Duplicate_Orders { const CRON_HOOK_NAME = 'newspack_wc_check_order_series'; const ADMIN_NOTICE_TRANSIENT_NAME = 'newspack_wc_check_order_series_admin_notice'; const DUPLICATED_ORDERS_OPTION_NAME = 'newspack_wc_order_series'; + const DISMISSED_DUPLICATE_ORDER_META_NAME = '_newspack_dismissed_duplicate'; /** * Initialize. * * @codeCoverageIgnore */ - public static function init() { + public static function init(): void { if ( ! wp_next_scheduled( self::CRON_HOOK_NAME ) ) { wp_schedule_event( time(), 'daily', self::CRON_HOOK_NAME ); } @@ -30,6 +31,13 @@ public static function init() { add_action( 'admin_notices', [ __CLASS__, 'display_admin_notice' ] ); } + /** + * Is this site using HPOS? + */ + private static function is_using_hpos(): bool { + return class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); + } + /** * Detect duplicate orders. * An order series will be detected if it the same amount, same day, from the same customer, @@ -37,12 +45,10 @@ public static function init() { * * @param int $series_interval The interval to consider for matching transactions. */ - public static function detect_order_series( $series_interval = 10 ) { + public static function detect_order_series( $series_interval = 10 ): array { global $wpdb; - $is_hpos_enabled = class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); - - if ( $is_hpos_enabled ) { + if ( self::is_using_hpos() ) { $query = " SELECT o_billing.email AS email, @@ -58,6 +64,11 @@ public static function detect_order_series( $series_interval = 10 ) { AND o.total_amount > 0.00 AND o.status = 'wc-completed' AND o_meta.order_id IS NULL + AND o.id NOT IN ( + SELECT order_id + FROM {$wpdb->prefix}wc_orders_meta + WHERE meta_key = '" . self::DISMISSED_DUPLICATE_ORDER_META_NAME . "' AND meta_value = '1' + ) AND EXISTS ( SELECT 1 FROM {$wpdb->prefix}wc_orders AS o2 @@ -88,6 +99,11 @@ public static function detect_order_series( $series_interval = 10 ) { WHERE p.post_type = 'shop_order' AND p.post_status = 'wc-completed' AND pm_email.meta_key = '_billing_email' + AND p.ID NOT IN ( + SELECT post_id + FROM {$wpdb->postmeta} + WHERE meta_key = '" . self::DISMISSED_DUPLICATE_ORDER_META_NAME . "' AND meta_value = '1' + ) AND pm_amount.meta_key = '_order_total' AND CAST(pm_amount.meta_value AS DECIMAL(10,2)) > 0.00 AND pm_renewal.post_id IS NULL @@ -111,7 +127,7 @@ public static function detect_order_series( $series_interval = 10 ) { /** * Add an admin notice about the detected order series. */ - public static function check_for_order_series() { + public static function check_for_order_series(): void { $order_series = self::detect_order_series(); if ( empty( $order_series ) ) { return; @@ -122,7 +138,7 @@ public static function check_for_order_series() { /** * Display an admin notice if duplicate orders are found. */ - public static function display_admin_notice() { + public static function display_admin_notice(): void { if ( ! function_exists( 'wc_price' ) ) { return; } @@ -135,7 +151,9 @@ public static function display_admin_notice() {

add_meta_data( self::DISMISSED_DUPLICATE_ORDER_META_NAME, 1 ); + $wc_order->save(); + } + } + self::check_for_order_series(); + // Refresh the page to reflect changes. + wp_safe_redirect( isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : admin_url() ); + exit; + } } } From a7b9eeb9d5e4f04e576ce0a3b550de6b85224ba0 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Tue, 19 Nov 2024 15:51:08 +0100 Subject: [PATCH 04/10] feat: better UX with a details element --- .../class-woocommerce-duplicate-orders.php | 80 ++++++++++--------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php index 345f2ddbaa..3925245f89 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php @@ -146,50 +146,52 @@ public static function display_admin_notice(): void { ?>
-

- -

-
    - -
  • -

    +

    + + + +
      + +
    • +

      - - - $order_id ) : - $order_url = admin_url( 'post.php?post=' . intval( $order_id ) . '&action=edit' ); + - + $order_id ) : + $order_url = admin_url( 'post.php?post=' . intval( $order_id ) . '&action=edit' ); + ?> + + -

      -
      - - 'dismiss_order_series_' . $order_series_id ] ); ?> -
      -
    • - -
    + 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_series['amount'] ) ), + esc_html( date_i18n( get_option( 'date_format' ), strtotime( $order_series['date'] ) ) ), + wp_kses_post( trim( $order_list ) ) + ); + + $order_series_id = implode( '-', $order_ids ); + ?> +

    +
    + + 'dismiss_order_series_' . $order_series_id ] ); ?> +
    +
  • + +
+
Date: Mon, 25 Nov 2024 12:26:43 +0100 Subject: [PATCH 05/10] feat: iterate orders instead of using a direct DB query --- .../class-woocommerce-duplicate-orders.php | 137 +++++++----------- 1 file changed, 53 insertions(+), 84 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php index 3925245f89..d054f78208 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php @@ -31,104 +31,73 @@ public static function init(): void { add_action( 'admin_notices', [ __CLASS__, 'display_admin_notice' ] ); } - /** - * Is this site using HPOS? - */ - private static function is_using_hpos(): bool { - return class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); - } - /** * Detect duplicate orders. * An order series will be detected if it the same amount, same day, from the same customer, * in the time span provided in the argument (in minutes). * - * @param int $series_interval The interval to consider for matching transactions. + * @param number $cutoff_time The cutoff time in the past (how many seconds ago). */ - public static function detect_order_series( $series_interval = 10 ): array { - global $wpdb; - - if ( self::is_using_hpos() ) { - $query = " - SELECT - o_billing.email AS email, - DATE(o.date_created_gmt) AS date, - o.total_amount AS amount, - GROUP_CONCAT(o.id) AS ids - FROM {$wpdb->prefix}wc_orders AS o - JOIN {$wpdb->prefix}wc_order_addresses AS o_billing - ON o.id = o_billing.order_id AND o_billing.address_type = 'billing' - LEFT JOIN {$wpdb->prefix}wc_orders_meta AS o_meta - ON o.id = o_meta.order_id AND o_meta.meta_key = '_subscription_renewal' - WHERE o_billing.email IS NOT NULL - AND o.total_amount > 0.00 - AND o.status = 'wc-completed' - AND o_meta.order_id IS NULL - AND o.id NOT IN ( - SELECT order_id - FROM {$wpdb->prefix}wc_orders_meta - WHERE meta_key = '" . self::DISMISSED_DUPLICATE_ORDER_META_NAME . "' AND meta_value = '1' - ) - AND EXISTS ( - SELECT 1 - FROM {$wpdb->prefix}wc_orders AS o2 - JOIN {$wpdb->prefix}wc_order_addresses AS o2_billing - ON o2.id = o2_billing.order_id AND o2_billing.address_type = 'billing' - WHERE o2_billing.email = o_billing.email - AND o2.total_amount = o.total_amount - AND ABS(TIMESTAMPDIFF(MINUTE, o2.date_created_gmt, o.date_created_gmt)) <= $series_interval - AND o2.id != o.id - ) - GROUP BY o_billing.email, DATE(o.date_created_gmt), o.total_amount - HAVING COUNT(*) > 1 -- Ensures only duplicates are included - "; - } else { - $query = " - SELECT - pm_email.meta_value AS email, - DATE(p.post_date) AS date, - pm_amount.meta_value AS amount, - GROUP_CONCAT(p.ID) AS ids - FROM {$wpdb->posts} AS p - JOIN {$wpdb->postmeta} AS pm_email - ON p.ID = pm_email.post_id - JOIN {$wpdb->postmeta} AS pm_amount - ON p.ID = pm_amount.post_id - LEFT JOIN {$wpdb->postmeta} AS pm_renewal - ON p.ID = pm_renewal.post_id AND pm_renewal.meta_key = '_subscription_renewal' - WHERE p.post_type = 'shop_order' - AND p.post_status = 'wc-completed' - AND pm_email.meta_key = '_billing_email' - AND p.ID NOT IN ( - SELECT post_id - FROM {$wpdb->postmeta} - WHERE meta_key = '" . self::DISMISSED_DUPLICATE_ORDER_META_NAME . "' AND meta_value = '1' - ) - AND pm_amount.meta_key = '_order_total' - AND CAST(pm_amount.meta_value AS DECIMAL(10,2)) > 0.00 - AND pm_renewal.post_id IS NULL - AND EXISTS ( - SELECT 1 - FROM {$wpdb->posts} AS p2 - WHERE p2.post_type = 'shop_order' - AND pm_email.meta_value = (SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = p2.ID AND meta_key = '_billing_email') - AND pm_amount.meta_value = (SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = p2.ID AND meta_key = '_order_total') - AND ABS(TIMESTAMPDIFF(MINUTE, p2.post_date, p.post_date)) <= $series_interval - AND p2.ID != p.ID - ) - GROUP BY pm_email.meta_value, DATE(p.post_date), pm_amount.meta_value - HAVING COUNT(*) > 1 -- Ensures only duplicates are included - "; + public static function get_order_series( $cutoff_time = MONTH_IN_SECONDS ): array { + $orders = wc_get_orders( + [ + 'limit' => -1, + 'status' => [ 'wc-completed' ], + 'date_completed' => '<' . ( time() - $cutoff_time ), + ] + ); + + $order_series = []; + + 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_series[ $email ] ) ) { + $order_series[ $email ] = []; + } + + if ( ! isset( $order_series[ $email ][ $amount ] ) ) { + $order_series[ $email ][ $amount ] = []; + } + + if ( ! isset( $order_series[ $email ][ $amount ][ $date ] ) ) { + $order_series[ $email ][ $amount ][ $date ] = []; + } + + $order_series[ $email ][ $amount ][ $date ][] = $order->get_id(); + } + + $duplicates = []; + + foreach ( $order_series as $email => $amounts ) { + foreach ( $amounts as $amount => $dates ) { + foreach ( $dates as $date => $order_ids ) { + if ( count( $order_ids ) > 1 ) { + $duplicates[] = [ + 'email' => $email, + 'amount' => $amount, + 'date' => $date, + 'ids' => implode( ',', $order_ids ), + ]; + } + } + } } - return $wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery + return $duplicates; } /** * Add an admin notice about the detected order series. */ public static function check_for_order_series(): void { - $order_series = self::detect_order_series(); + $order_series = self::get_order_series(); if ( empty( $order_series ) ) { return; } From 3468af6af6b92f2207546264ae57ab225d58ee27 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Thu, 28 Nov 2024 11:04:34 +0100 Subject: [PATCH 06/10] chore: unify wording --- .../class-woocommerce-duplicate-orders.php | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php index d054f78208..ef2e8315c6 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php @@ -13,9 +13,9 @@ * Adds an admin notice when possibly duplicated orders are detected. */ class WooCommerce_Duplicate_Orders { - const CRON_HOOK_NAME = 'newspack_wc_check_order_series'; - const ADMIN_NOTICE_TRANSIENT_NAME = 'newspack_wc_check_order_series_admin_notice'; - const DUPLICATED_ORDERS_OPTION_NAME = 'newspack_wc_order_series'; + 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_DUPLICATE_ORDER_META_NAME = '_newspack_dismissed_duplicate'; /** @@ -27,27 +27,26 @@ 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_series' ] ); + add_action( self::CRON_HOOK_NAME, [ __CLASS__, 'check_for_order_duplicates' ] ); add_action( 'admin_notices', [ __CLASS__, 'display_admin_notice' ] ); } /** * Detect duplicate orders. - * An order series will be detected if it the same amount, same day, from the same customer, - * in the time span provided in the argument (in minutes). + * 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). */ - public static function get_order_series( $cutoff_time = MONTH_IN_SECONDS ): array { + public static function get_order_duplicates( $cutoff_time = MONTH_IN_SECONDS ): array { $orders = wc_get_orders( [ 'limit' => -1, 'status' => [ 'wc-completed' ], - 'date_completed' => '<' . ( time() - $cutoff_time ), + 'date_completed' => '>' . ( time() - $cutoff_time ), ] ); - $order_series = []; + $order_duplicates = []; foreach ( $orders as $order ) { $email = $order->get_billing_email(); @@ -58,28 +57,29 @@ public static function get_order_series( $cutoff_time = MONTH_IN_SECONDS ): arra continue; } - if ( ! isset( $order_series[ $email ] ) ) { - $order_series[ $email ] = []; + if ( ! isset( $order_duplicates[ $email ] ) ) { + $order_duplicates[ $email ] = []; } - if ( ! isset( $order_series[ $email ][ $amount ] ) ) { - $order_series[ $email ][ $amount ] = []; + if ( ! isset( $order_duplicates[ $email ][ $amount ] ) ) { + $order_duplicates[ $email ][ $amount ] = []; } - if ( ! isset( $order_series[ $email ][ $amount ][ $date ] ) ) { - $order_series[ $email ][ $amount ][ $date ] = []; + if ( ! isset( $order_duplicates[ $email ][ $amount ][ $date ] ) ) { + $order_duplicates[ $email ][ $amount ][ $date ] = []; } - $order_series[ $email ][ $amount ][ $date ][] = $order->get_id(); + $order_duplicates[ $email ][ $amount ][ $date ][] = $order->get_id(); } - $duplicates = []; + $results = []; - foreach ( $order_series as $email => $amounts ) { + foreach ( $order_duplicates as $email => $amounts ) { foreach ( $amounts as $amount => $dates ) { foreach ( $dates as $date => $order_ids ) { if ( count( $order_ids ) > 1 ) { - $duplicates[] = [ + sort( $order_ids ); + $results[] = [ 'email' => $email, 'amount' => $amount, 'date' => $date, @@ -90,18 +90,18 @@ public static function get_order_series( $cutoff_time = MONTH_IN_SECONDS ): arra } } - return $duplicates; + return $results; } /** - * Add an admin notice about the detected order series. + * Check for duplicate orders and save the result in an option. */ - public static function check_for_order_series(): void { - $order_series = self::get_order_series(); - if ( empty( $order_series ) ) { + public static function check_for_order_duplicates(): void { + $order_duplicates = self::get_order_duplicates(); + if ( empty( $order_duplicates ) ) { return; } - update_option( self::DUPLICATED_ORDERS_OPTION_NAME, $order_series ); + update_option( self::DUPLICATED_ORDERS_OPTION_NAME, $order_duplicates ); } /** @@ -111,7 +111,7 @@ public static function display_admin_notice(): void { if ( ! function_exists( 'wc_price' ) ) { return; } - $order_series = get_option( self::DUPLICATED_ORDERS_OPTION_NAME, [] ); + $order_duplicates = get_option( self::DUPLICATED_ORDERS_OPTION_NAME, [] ); ?>
@@ -120,19 +120,19 @@ public static function display_admin_notice(): void {
    - +
  • - + $order_id ) : $order_url = admin_url( 'post.php?post=' . intval( $order_id ) . '&action=edit' ); ?> @@ -145,17 +145,17 @@ public static function display_admin_notice(): void { /* 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_series['amount'] ) ), - esc_html( date_i18n( get_option( 'date_format' ), strtotime( $order_series['date'] ) ) ), + 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_series_id = implode( '-', $order_ids ); + $order_duplicates_id = implode( '-', $order_ids ); ?>

    - - 'dismiss_order_series_' . $order_series_id ] ); ?> + + 'dismiss_order_duplicates_' . $order_duplicates_id ] ); ?>
  • @@ -172,7 +172,7 @@ public static function display_admin_notice(): void { $wc_order->save(); } } - self::check_for_order_series(); + self::check_for_order_duplicates(); // Refresh the page to reflect changes. wp_safe_redirect( isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : admin_url() ); exit; From 224b24e95a2ad73d362ff3129143b9069b461609 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Thu, 28 Nov 2024 12:05:19 +0100 Subject: [PATCH 07/10] fix: handle the order meta --- .../woocommerce/class-woocommerce-duplicate-orders.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php index ef2e8315c6..2b7c0bcdda 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php @@ -53,7 +53,11 @@ public static function get_order_duplicates( $cutoff_time = MONTH_IN_SECONDS ): $amount = $order->get_total(); $date = $order->get_date_created()->date( 'Y-m-d' ); - if ( \wcs_order_contains_renewal( $order ) || \wcs_order_contains_resubscribe( $order ) ) { + if ( + \wcs_order_contains_renewal( $order ) || + \wcs_order_contains_resubscribe( $order ) || + $order->get_meta( self::DISMISSED_DUPLICATE_ORDER_META_NAME ) + ) { continue; } From 0fa853eb1487f0b75efa9ab5ca5c47d8aac50185 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Thu, 28 Nov 2024 12:32:04 +0100 Subject: [PATCH 08/10] feat: CLI tool --- .../class-woocommerce-duplicate-orders.php | 95 ++++++++++++++----- 1 file changed, 71 insertions(+), 24 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php index 2b7c0bcdda..2798f9b643 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php @@ -16,7 +16,7 @@ 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_DUPLICATE_ORDER_META_NAME = '_newspack_dismissed_duplicate'; + const DISMISSED_DUPLICATES_OPTION_NAME = 'newspack_wc_order_duplicates_dismissed'; /** * Initialize. @@ -29,6 +29,10 @@ public static function init(): void { } 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' ] ); + } } /** @@ -37,7 +41,7 @@ public static function init(): void { * * @param number $cutoff_time The cutoff time in the past (how many seconds ago). */ - public static function get_order_duplicates( $cutoff_time = MONTH_IN_SECONDS ): array { + private static function get_order_duplicates( $cutoff_time ): array { $orders = wc_get_orders( [ 'limit' => -1, @@ -53,11 +57,7 @@ public static function get_order_duplicates( $cutoff_time = MONTH_IN_SECONDS ): $amount = $order->get_total(); $date = $order->get_date_created()->date( 'Y-m-d' ); - if ( - \wcs_order_contains_renewal( $order ) || - \wcs_order_contains_resubscribe( $order ) || - $order->get_meta( self::DISMISSED_DUPLICATE_ORDER_META_NAME ) - ) { + if ( \wcs_order_contains_renewal( $order ) || \wcs_order_contains_resubscribe( $order ) ) { continue; } @@ -83,11 +83,12 @@ public static function get_order_duplicates( $cutoff_time = MONTH_IN_SECONDS ): foreach ( $dates as $date => $order_ids ) { if ( count( $order_ids ) > 1 ) { sort( $order_ids ); - $results[] = [ + $ids = implode( ',', $order_ids ); + $results[ $ids ] = [ 'email' => $email, 'amount' => $amount, 'date' => $date, - 'ids' => implode( ',', $order_ids ), + 'ids' => $ids, ]; } } @@ -99,13 +100,29 @@ public static function get_order_duplicates( $cutoff_time = MONTH_IN_SECONDS ): /** * 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(): void { - $order_duplicates = self::get_order_duplicates(); + public static function check_for_order_duplicates( $cutoff_time = MONTH_IN_SECONDS, $save = false, $upsert = true ): array { + $order_duplicates = self::get_order_duplicates( $cutoff_time ); if ( empty( $order_duplicates ) ) { - return; + return []; } - update_option( self::DUPLICATED_ORDERS_OPTION_NAME, $order_duplicates ); + 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; } /** @@ -115,7 +132,8 @@ public static function display_admin_notice(): void { if ( ! function_exists( 'wc_price' ) ) { return; } - $order_duplicates = get_option( self::DUPLICATED_ORDERS_OPTION_NAME, [] ); + $existing_order_duplicates = get_option( self::DUPLICATED_ORDERS_OPTION_NAME, [] ); + $dismissed_duplicates = get_option( self::DISMISSED_DUPLICATES_OPTION_NAME, [] ); ?>
    @@ -124,7 +142,11 @@ public static function display_admin_notice(): void {
      - + +
    • @@ -168,20 +190,45 @@ public static function display_admin_notice(): void {

    add_meta_data( self::DISMISSED_DUPLICATE_ORDER_META_NAME, 1 ); - $wc_order->save(); - } - } - self::check_for_order_duplicates(); + $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 upsert the order duplicates with a specified timeframe. + * + * ## OPTIONS + * + * [--cutoff_time=] + * : 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(); From 97bb2a1764feecf8ed0789ee17a5963b62775e2f Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Thu, 28 Nov 2024 12:53:51 +0100 Subject: [PATCH 09/10] feat: process orders in batches --- .../class-woocommerce-duplicate-orders.php | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php index 2798f9b643..6bf39f835c 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php @@ -40,16 +40,26 @@ public static function init(): void { * 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. */ - private static function get_order_duplicates( $cutoff_time ): array { - $orders = wc_get_orders( + public static function get_order_duplicates( $cutoff_time, $current_page = 0, $results = [] ): array { + $per_page = 100; + $order_result = wc_get_orders( [ - 'limit' => -1, + '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 ) { @@ -76,8 +86,6 @@ private static function get_order_duplicates( $cutoff_time ): array { $order_duplicates[ $email ][ $amount ][ $date ][] = $order->get_id(); } - $results = []; - foreach ( $order_duplicates as $email => $amounts ) { foreach ( $amounts as $amount => $dates ) { foreach ( $dates as $date => $order_ids ) { @@ -95,6 +103,11 @@ private static function get_order_duplicates( $cutoff_time ): array { } } + if ( $order_result->total > 0 ) { + $current_page++; + return self::get_order_duplicates( $cutoff_time, $current_page, $results ); + } + return $results; } From 520f49c1367f00aebf272734c855943de2c7080a Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Fri, 29 Nov 2024 09:03:49 +0100 Subject: [PATCH 10/10] feat: tweak --- .../woocommerce/class-woocommerce-duplicate-orders.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php index 6bf39f835c..310747a469 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-duplicate-orders.php @@ -118,7 +118,7 @@ public static function get_order_duplicates( $cutoff_time, $current_page = 0, $r * @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 = MONTH_IN_SECONDS, $save = false, $upsert = true ): array { + 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 []; @@ -146,6 +146,9 @@ public static function display_admin_notice(): void { 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, [] ); ?>
    @@ -212,7 +215,7 @@ public static function display_admin_notice(): void { } /** - * CLI handler to upsert the order duplicates with a specified timeframe. + * CLI handler to search for duplicates and optionally store this info to be displayed in the admin panel. * * ## OPTIONS *