From 4436d41dacd0521351a826cd37b58760128b828b Mon Sep 17 00:00:00 2001 From: dkoo Date: Thu, 17 Oct 2024 15:32:38 -0600 Subject: [PATCH 01/11] fix: command to fix active subs w/ missing next_payment dates --- .../woocommerce/class-woocommerce-cli.php | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php index ae11ecbb7b..5f93c19cbf 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php @@ -162,6 +162,131 @@ function( $subscription ) { } } + /** + * Fixes or reports active subscriptions that have missed next payment dates. + * By default, will only process subscriptions started in the past 90 days. + * + * ## OPTIONS + * + * [--dry-run] + * : If set, will report results but will not make any changes. + * + * [--batch-size=] + * : The number of subscriptions to process in each batch. Default: 50. + * + * [--start-date=] + * : A date string in YYYY-MM-DD format to use as the start date for the script. Default: 90 days ago. + * + * @param array $args Positional args. + * @param array $assoc_args Associative args. + */ + public function fix_missing_next_payment_dates( $args, $assoc_args ) { + $dry_run = ! empty( $assoc_args['dry-run'] ); + $now = time(); + $batch_size = ! empty( $assoc_args['batch-size'] ) ? intval( $assoc_args['batch-size'] ) : 50; + $start_date = ! empty( $assoc_args['start-date'] ) ? strtotime( $assoc_args['start-date'] ) : strtotime( '-90 days', $now ); + + \WP_CLI::log( + ' +Fetching active subscriptions with missing or missed next_payment dates... + ' + ); + + $query_args = [ + 'subscriptions_per_page' => $batch_size, + 'subscription_status' => WooCommerce_Connection::ACTIVE_SUBSCRIPTION_STATUSES, + 'offset' => 0, + ]; + $processed = 0; + $subscriptions = \wcs_get_subscriptions( $query_args ); + $total_revenue = 0; + $results = []; + + while ( ! empty( $subscriptions ) ) { + foreach ( $subscriptions as $subscription_id => $subscription ) { + array_shift( $subscriptions ); + $subscription_start = $subscription->get_date( 'start_date' ); + + // If the subscription start date is before the $args start date, we're done. + if ( strtotime( $subscription_start ) < $start_date ) { + $subscriptions = []; + break; + } + + $next_payment_date = $subscription->get_date( 'next_payment' ); + $is_in_past = ! strtotime( $next_payment_date ) || strtotime( $next_payment_date ) < $now; + + // Subscription has a valid next payment date and it's in the future, so skip. + if ( $next_payment_date && ! $is_in_past ) { + continue; + } + + $result = [ + 'ID' => $subscription->get_id(), + 'start_date' => $subscription_start, + 'next_payment_date' => $next_payment_date, + 'billing_period' => $subscription->get_billing_period(), + 'missed_periods' => 0, + 'missed_total' => 0, + ]; + $min_date = strtotime( $subscription_start ); + while ( $min_date <= $now ) { + $result['missed_periods']++; + $min_date = strtotime( '+1 ' . $result['billing_period'], $min_date ); + } + + if ( $result['missed_periods'] ) { + $result['missed_total'] += $subscription->get_total() * $result['missed_periods']; + $total_revenue += $result['missed_total']; + } + + if ( ! $dry_run ) { + $subscription->update_dates( + [ + 'next_payment' => $subscription->calculate_date( 'next_payment' ), + ] + ); + $subscription->save(); + $result['next_payment_date'] = $subscription->get_date( 'next_payment' ); + } + + $results[] = $result; + $processed++; + + // Get the next batch. + if ( empty( $subscriptions ) ) { + $query_args['offset'] += $batch_size; + $subscriptions = \wcs_get_subscriptions( $query_args ); + } + } + } + + if ( empty( $results ) ) { + \WP_CLI::log( 'No subscriptions with missing next_payment dates found in the given time period.' ); + } else { + \WP_CLI\Utils\format_items( + 'table', + $results, + [ + 'ID', + 'start_date', + 'next_payment_date', + 'billing_period', + 'missed_periods', + 'missed_total', + ] + ); + \WP_CLI::success( + sprintf( + 'Finished processing %d subscriptions. %s', + $processed, + $total_revenue ? 'Total missed revenue: ' . \wp_strip_all_tags( html_entity_decode( \wc_price( $total_revenue ) ) ) : '' + ) + ); + } + \WP_CLI::line( '' ); + } + /** * Outputs a list of subscription in CLI * From 90c075b5468b48686205c21676429eee03d5f425 Mon Sep 17 00:00:00 2001 From: dkoo Date: Thu, 17 Oct 2024 15:35:13 -0600 Subject: [PATCH 02/11] fix: don't process pending-cancel subs --- includes/reader-revenue/woocommerce/class-woocommerce-cli.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php index 5f93c19cbf..4521c98fe7 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php @@ -194,7 +194,7 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { $query_args = [ 'subscriptions_per_page' => $batch_size, - 'subscription_status' => WooCommerce_Connection::ACTIVE_SUBSCRIPTION_STATUSES, + 'subscription_status' => [ 'active', 'pending' ], 'offset' => 0, ]; $processed = 0; @@ -223,6 +223,7 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { $result = [ 'ID' => $subscription->get_id(), + 'status' => $subscription->get_status(), 'start_date' => $subscription_start, 'next_payment_date' => $next_payment_date, 'billing_period' => $subscription->get_billing_period(), @@ -269,6 +270,7 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { $results, [ 'ID', + 'status', 'start_date', 'next_payment_date', 'billing_period', From 6e5c96f4bc39f057436db1119468e384aa3b118a Mon Sep 17 00:00:00 2001 From: dkoo Date: Wed, 23 Oct 2024 16:21:59 -0600 Subject: [PATCH 03/11] fix: check for billing period + interval, and account for in-progress --- .../woocommerce/class-woocommerce-cli.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php index 4521c98fe7..dd75d448a2 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php @@ -221,7 +221,7 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { continue; } - $result = [ + $result = [ 'ID' => $subscription->get_id(), 'status' => $subscription->get_status(), 'start_date' => $subscription_start, @@ -230,10 +230,14 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { 'missed_periods' => 0, 'missed_total' => 0, ]; - $min_date = strtotime( $subscription_start ); - while ( $min_date <= $now ) { - $result['missed_periods']++; - $min_date = strtotime( '+1 ' . $result['billing_period'], $min_date ); + if ( ! empty( $result['billing_period'] ) && ! empty( $result['billing_interval'] ) ) { + $period = $result['billing_period']; + $interval = (int) $result['billing_interval']; + $min_date = strtotime( "+$interval $period", strtotime( $subscription_start ) ); // Start after first period so we don't count in-progress periods as missed. + while ( $min_date <= $now ) { + $result['missed_periods']++; + $min_date = strtotime( "+$interval $period", $min_date ); + } } if ( $result['missed_periods'] ) { From 6e91f694b300c4c4260edeae50cb1ae5581ab615 Mon Sep 17 00:00:00 2001 From: dkoo Date: Thu, 24 Oct 2024 09:14:52 -0600 Subject: [PATCH 04/11] fix: skip broken subscriptions --- .../woocommerce/class-woocommerce-cli.php | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php index dd75d448a2..65269312a3 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php @@ -230,14 +230,18 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { 'missed_periods' => 0, 'missed_total' => 0, ]; - if ( ! empty( $result['billing_period'] ) && ! empty( $result['billing_interval'] ) ) { - $period = $result['billing_period']; - $interval = (int) $result['billing_interval']; - $min_date = strtotime( "+$interval $period", strtotime( $subscription_start ) ); // Start after first period so we don't count in-progress periods as missed. - while ( $min_date <= $now ) { - $result['missed_periods']++; - $min_date = strtotime( "+$interval $period", $min_date ); - } + + // Can't process a broken subscription (missing a billing period or interval). + if ( empty( $result['billing_period'] ) || empty( $result['billing_interval'] ) ) { + continue; + } + + $period = $result['billing_period']; + $interval = (int) $result['billing_interval']; + $min_date = strtotime( "+$interval $period", strtotime( $subscription_start ) ); // Start after first period so we don't count in-progress periods as missed. + while ( $min_date <= $now ) { + $result['missed_periods']++; + $min_date = strtotime( "+$interval $period", $min_date ); } if ( $result['missed_periods'] ) { From ba8870036333b1808c391d4358468ec5874caf9d Mon Sep 17 00:00:00 2001 From: Derrick Koo Date: Fri, 25 Oct 2024 10:46:39 -0600 Subject: [PATCH 05/11] Update includes/reader-revenue/woocommerce/class-woocommerce-cli.php Co-authored-by: Adam Cassis --- includes/reader-revenue/woocommerce/class-woocommerce-cli.php | 1 + 1 file changed, 1 insertion(+) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php index 65269312a3..04b490a127 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php @@ -227,6 +227,7 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { 'start_date' => $subscription_start, 'next_payment_date' => $next_payment_date, 'billing_period' => $subscription->get_billing_period(), + 'billing_interval' => $subscription->get_billing_interval(), 'missed_periods' => 0, 'missed_total' => 0, ]; From 9686c86df4acb4d7c9002a4722a5f6c03e73e8bf Mon Sep 17 00:00:00 2001 From: dkoo Date: Fri, 1 Nov 2024 15:01:21 -0600 Subject: [PATCH 06/11] fix: account for successful orders and end dates --- .../woocommerce/class-woocommerce-cli.php | 128 ++++++++++++------ 1 file changed, 86 insertions(+), 42 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php index 04b490a127..d34d256d8f 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php @@ -186,6 +186,13 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { $batch_size = ! empty( $assoc_args['batch-size'] ) ? intval( $assoc_args['batch-size'] ) : 50; $start_date = ! empty( $assoc_args['start-date'] ) ? strtotime( $assoc_args['start-date'] ) : strtotime( '-90 days', $now ); + if ( ! $dry_run ) { + \WP_CLI::line( "\n=====================\n= LIVE MODE =\n=====================\n" ); + } else { + \WP_CLI::line( "\n===================\n= DRY RUN =\n===================\n" ); + } + sleep( 2 ); + \WP_CLI::log( ' Fetching active subscriptions with missing or missed next_payment dates... @@ -205,61 +212,22 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { while ( ! empty( $subscriptions ) ) { foreach ( $subscriptions as $subscription_id => $subscription ) { array_shift( $subscriptions ); - $subscription_start = $subscription->get_date( 'start_date' ); // If the subscription start date is before the $args start date, we're done. - if ( strtotime( $subscription_start ) < $start_date ) { + if ( strtotime( $subscription->get_date( 'start_date' ) ) < $start_date ) { $subscriptions = []; break; } - $next_payment_date = $subscription->get_date( 'next_payment' ); - $is_in_past = ! strtotime( $next_payment_date ) || strtotime( $next_payment_date ) < $now; - - // Subscription has a valid next payment date and it's in the future, so skip. - if ( $next_payment_date && ! $is_in_past ) { - continue; - } - - $result = [ - 'ID' => $subscription->get_id(), - 'status' => $subscription->get_status(), - 'start_date' => $subscription_start, - 'next_payment_date' => $next_payment_date, - 'billing_period' => $subscription->get_billing_period(), - 'billing_interval' => $subscription->get_billing_interval(), - 'missed_periods' => 0, - 'missed_total' => 0, - ]; - - // Can't process a broken subscription (missing a billing period or interval). - if ( empty( $result['billing_period'] ) || empty( $result['billing_interval'] ) ) { + $result = self::calculate_next_payment_date( $subscription, $dry_run ); + if ( ! $result ) { continue; } - $period = $result['billing_period']; - $interval = (int) $result['billing_interval']; - $min_date = strtotime( "+$interval $period", strtotime( $subscription_start ) ); // Start after first period so we don't count in-progress periods as missed. - while ( $min_date <= $now ) { - $result['missed_periods']++; - $min_date = strtotime( "+$interval $period", $min_date ); - } - if ( $result['missed_periods'] ) { - $result['missed_total'] += $subscription->get_total() * $result['missed_periods']; $total_revenue += $result['missed_total']; } - if ( ! $dry_run ) { - $subscription->update_dates( - [ - 'next_payment' => $subscription->calculate_date( 'next_payment' ), - ] - ); - $subscription->save(); - $result['next_payment_date'] = $subscription->get_date( 'next_payment' ); - } - $results[] = $result; $processed++; @@ -282,6 +250,7 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { 'status', 'start_date', 'next_payment_date', + 'end_date', 'billing_period', 'missed_periods', 'missed_total', @@ -298,6 +267,81 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { \WP_CLI::line( '' ); } + /** + * Given a subscription, calculates the next payment date and missed payments. + * + * @param WC_Subscription $subscription The subscription. + * @param bool $dry_run If set, will not make any changes. + * + * @return array|false The result array or false if the subscription is broken. + */ + public static function calculate_next_payment_date( $subscription, $dry_run = false ) { + $now = time(); + $subscription_start = $subscription->get_date( 'start_date' ); + $next_payment_date = $subscription->get_date( 'next_payment' ); + $is_in_past = ! strtotime( $next_payment_date ) || strtotime( $next_payment_date ) < $now; + + // Subscription has a valid next payment date and it's in the future, so skip. + if ( $next_payment_date && ! $is_in_past ) { + return false; + } + + $result = [ + 'ID' => $subscription->get_id(), + 'status' => $subscription->get_status(), + 'start_date' => $subscription_start, + 'next_payment_date' => $next_payment_date, + 'end_date' => $subscription->get_date( 'end' ), + 'billing_period' => $subscription->get_billing_period(), + 'billing_interval' => $subscription->get_billing_interval(), + 'missed_periods' => 0, + 'missed_total' => 0, + ]; + + // Can't process a broken subscription (missing a billing period or interval). + if ( empty( $result['billing_period'] ) || empty( $result['billing_interval'] ) ) { + return false; + } + + $period = $result['billing_period']; + $interval = (int) $result['billing_interval']; + $min_date = strtotime( "+$interval $period", strtotime( $subscription_start ) ); // Start after first period so we don't count in-progress periods as missed. + $end_date = $now; + + // If there were successful orders for this subscription, start from the last one. + $last_order = $subscription->get_last_order( 'all', 'any', [ 'pending', 'processing', 'on-hold', 'cancelled', 'refunded', 'failed' ] ); + if ( $last_order && $last_order->get_date_completed() ) { + $min_date = strtotime( "+$interval $period", $last_order->get_date_completed()->getOffsetTimestamp() ); + } + + // If there's an end date, end there. + if ( ! empty( $result['end_date'] ) ) { + $end = strtotime( $result['end_date'] ); + } + + while ( $min_date <= $end ) { + $result['missed_periods']++; + $min_date = strtotime( "+$interval $period", $min_date ); + } + + if ( $result['missed_periods'] ) { + $result['missed_total'] += $subscription->get_total() * $result['missed_periods']; + } + + $calculated_next_payment = $subscription->calculate_date( 'next_payment' ); + $result['next_payment_date'] = $calculated_next_payment; + if ( ! $dry_run && ( ! $end_date || $end > strtotime( $calculated_next_payment ) ) ) { + $subscription->update_dates( + [ + 'next_payment' => $calculated_next_payment, + ] + ); + $subscription->save(); + } + + return $result; + } + /** * Outputs a list of subscription in CLI * From ff7c66572a27ca5411d3d6aff353fff04174d607 Mon Sep 17 00:00:00 2001 From: dkoo Date: Fri, 1 Nov 2024 16:09:15 -0600 Subject: [PATCH 07/11] test: add unit tests --- .../woocommerce/class-woocommerce-cli.php | 28 +-- tests/mocks/wc-mocks.php | 82 ++++++++ tests/unit-tests/woocommerce-cli.php | 193 ++++++++++++++++++ 3 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 tests/unit-tests/woocommerce-cli.php diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php index d34d256d8f..851da4bb7b 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php @@ -214,7 +214,7 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { array_shift( $subscriptions ); // If the subscription start date is before the $args start date, we're done. - if ( strtotime( $subscription->get_date( 'start_date' ) ) < $start_date ) { + if ( strtotime( $subscription->get_date( 'start' ) ) < $start_date ) { $subscriptions = []; break; } @@ -277,7 +277,7 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { */ public static function calculate_next_payment_date( $subscription, $dry_run = false ) { $now = time(); - $subscription_start = $subscription->get_date( 'start_date' ); + $subscription_start = $subscription->get_date( 'start' ); $next_payment_date = $subscription->get_date( 'next_payment' ); $is_in_past = ! strtotime( $next_payment_date ) || strtotime( $next_payment_date ) < $now; @@ -316,10 +316,10 @@ public static function calculate_next_payment_date( $subscription, $dry_run = fa // If there's an end date, end there. if ( ! empty( $result['end_date'] ) ) { - $end = strtotime( $result['end_date'] ); + $end_date = strtotime( $result['end_date'] ); } - while ( $min_date <= $end ) { + while ( $min_date <= $end_date ) { $result['missed_periods']++; $min_date = strtotime( "+$interval $period", $min_date ); } @@ -328,15 +328,17 @@ public static function calculate_next_payment_date( $subscription, $dry_run = fa $result['missed_total'] += $subscription->get_total() * $result['missed_periods']; } - $calculated_next_payment = $subscription->calculate_date( 'next_payment' ); - $result['next_payment_date'] = $calculated_next_payment; - if ( ! $dry_run && ( ! $end_date || $end > strtotime( $calculated_next_payment ) ) ) { - $subscription->update_dates( - [ - 'next_payment' => $calculated_next_payment, - ] - ); - $subscription->save(); + $calculated_next_payment = $subscription->calculate_date( 'next_payment' ); + if ( ! $result['end_date'] || strtotime( $result['end_date'] ) > strtotime( $calculated_next_payment ) ) { + $result['next_payment_date'] = $calculated_next_payment; + if ( ! $dry_run ) { + $subscription->update_dates( + [ + 'next_payment' => $calculated_next_payment, + ] + ); + $subscription->save(); + } } return $result; diff --git a/tests/mocks/wc-mocks.php b/tests/mocks/wc-mocks.php index 119a6921af..e09c466675 100644 --- a/tests/mocks/wc-mocks.php +++ b/tests/mocks/wc-mocks.php @@ -40,6 +40,9 @@ class WC_DateTime extends DateTime { public function date( $format ) { return gmdate( $format, $this->getTimestamp() ); } + public function getOffsetTimestamp() { + return $this->getTimestamp() + $this->getOffset(); + } } class WC_Customer { @@ -112,12 +115,91 @@ public function get_items() { public function get_date_paid() { return new WC_DateTime( $this->data['date_paid'] ); } + public function get_date_completed() { + return new WC_DateTime( $this->data['date_completed'] ); + } + public function get_total() { + return $this->data['total']; + } + public function get_status() { + return $this->data['status']; + } +} + +class WC_Subscription { + public $data = []; + public $meta = []; + public $orders = []; + public function __construct( $data ) { + $this->data = array_merge( $data, $this->data ); + if ( isset( $data['meta'] ) ) { + $this->meta = $data['meta']; + } + if ( isset( $data['orders'] ) ) { + $this->orders = $data['orders']; + usort( + $this->orders, + function( $a, $b ) { + return $b->get_date_paid()->getTimestamp() <=> $a->get_date_paid()->getTimestamp(); + } + ); + } + } + public function get_id() { + return $this->data['id']; + } + public function get_customer_id() { + return $this->data['customer_id']; + } + public function get_meta( $field_name ) { + return isset( $this->meta[ $field_name ] ) ? $this->meta[ $field_name ] : ''; + } + public function has_status( $statuses ) { + return in_array( $this->data['status'], $statuses ); + } + public function get_date_paid() { + return new WC_DateTime( $this->data['date_paid'] ); + } public function get_total() { return $this->data['total']; } public function get_status() { return $this->data['status']; } + public function get_billing_period() { + return $this->data['billing_period']; + } + public function get_billing_interval() { + return $this->data['billing_interval']; + } + public function get_last_order() { + if ( ! empty( $this->orders ) ) { + return end( $this->orders ); + } + return false; + } + public function get_date( $type ) { + return $this->data['dates'][ $type ] ?? 0; + } + public function calculate_date() { + $start = strtotime( $this->get_date( 'start' ) ); + $interval = $this->get_billing_interval(); + $period = $this->get_billing_period(); + $end = time(); + + while ( $start <= $end ) { + $start = strtotime( "+$interval $period", $start ); + } + return gmdate( 'Y-m-d H:i:s', $start ); + } + public function update_dates( $dates ) { + foreach ( $dates as $type => $date ) { + $this->data['dates'][ $type ] = $date; + } + } + public function save() { + return true; + } } function wc_create_order( $data ) { diff --git a/tests/unit-tests/woocommerce-cli.php b/tests/unit-tests/woocommerce-cli.php new file mode 100644 index 0000000000..846348750c --- /dev/null +++ b/tests/unit-tests/woocommerce-cli.php @@ -0,0 +1,193 @@ + 'test_user', + 'user_email' => 'test@example.com', + 'user_pass' => 'password', + 'meta_input' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'wc_total_spent' => 100, + ], + ] + ); + } + + /** + * Test a healthy subscription that has a future next_payment date. + */ + public function test_healthy_subscription() { + $subscription = new WC_Subscription( + [ + 'id' => 1, + 'status' => 'active', + 'billing_period' => 'month', + 'billing_interval' => 1, + 'total' => 10, // Amount of recurring payment. + 'dates' => [ + 'start' => gmdate( 'Y-m-d H:i:s', strtotime( '-6 month' ) ), + 'next_payment' => gmdate( 'Y-m-d H:i:s', strtotime( '+10 day' ) ), + ], + 'orders' => [ + wc_create_order( + [ + 'customer_id' => self::$user_id, + 'status' => 'completed', + 'total' => 10, + 'date_completed' => gmdate( 'Y-m-d H:i:s', strtotime( '-1 month' ) ), + ] + ), + ], + ] + ); + $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); + + // Subscription wasn't processed. + $this->assertFalse( $result ); + } + + /** + * Test a subscription that's missing a next payment date and has no successful orders. + */ + public function test_missing_next_payment_date() { + $subscription = new WC_Subscription( + [ + 'id' => 2, + 'status' => 'active', + 'billing_period' => 'month', + 'billing_interval' => 1, + 'total' => 10, // Amount of recurring payment. + 'dates' => [ + 'start' => gmdate( 'Y-m-d H:i:s', strtotime( '-6 month' ) ), + ], + ] + ); + $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); + + // Subscription was processed. + $this->assertEquals( + $result, + [ + 'ID' => $subscription->get_id(), + 'status' => $subscription->get_status(), + 'start_date' => $subscription->get_date( 'start' ), + 'next_payment_date' => $subscription->calculate_date( 'next_payment' ), + 'end_date' => $subscription->get_date( 'end' ), + 'billing_period' => $subscription->get_billing_period(), + 'billing_interval' => $subscription->get_billing_interval(), + 'missed_periods' => 6, + 'missed_total' => 60, + ] + ); + + // Next payment date is now in the future. + $this->assertGreaterThan( time(), strtotime( $result['next_payment_date'] ) ); + } + + /** + * Test a subscription that's missing a next payment date but has a successful order. + */ + public function test_missing_next_payment_date_with_order() { + $subscription = new WC_Subscription( + [ + 'id' => 3, + 'status' => 'active', + 'billing_period' => 'month', + 'billing_interval' => 1, + 'total' => 10, // Amount of recurring payment. + 'dates' => [ + 'start' => gmdate( 'Y-m-d H:i:s', strtotime( '-6 month' ) ), + ], + 'orders' => [ + wc_create_order( + [ + 'customer_id' => self::$user_id, + 'status' => 'completed', + 'total' => 10, + 'date_completed' => gmdate( 'Y-m-d H:i:s', strtotime( '-3 month' ) ), + ] + ), + ], + ] + ); + $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); + + // Subscription was processed. + $this->assertEquals( + $result, + [ + 'ID' => $subscription->get_id(), + 'status' => $subscription->get_status(), + 'start_date' => $subscription->get_date( 'start' ), + 'next_payment_date' => $subscription->calculate_date( 'next_payment' ), + 'end_date' => $subscription->get_date( 'end' ), + 'billing_period' => $subscription->get_billing_period(), + 'billing_interval' => $subscription->get_billing_interval(), + 'missed_periods' => 3, + 'missed_total' => 30, + ] + ); + + // Next payment date is now in the future. + $this->assertGreaterThan( time(), strtotime( $result['next_payment_date'] ) ); + } + + /** + * Test a subscription that's missing a next payment date but has an end date before the calculated next payment date. + */ + public function test_missing_next_payment_date_with_end_date() { + $subscription = new WC_Subscription( + [ + 'id' => 4, + 'status' => 'active', + 'billing_period' => 'month', + 'billing_interval' => 1, + 'total' => 10, // Amount of recurring payment. + 'dates' => [ + 'start' => gmdate( 'Y-m-d H:i:s', strtotime( '-6 month' ) ), + 'end' => gmdate( 'Y-m-d H:i:s', strtotime( '+3 day' ) ), + ], + ] + ); + $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); + + // Subscription was processed, but next payment date not set because the end date will occur first.. + $this->assertEquals( + $result, + [ + 'ID' => $subscription->get_id(), + 'status' => $subscription->get_status(), + 'start_date' => $subscription->get_date( 'start' ), + 'next_payment_date' => 0, + 'end_date' => $subscription->get_date( 'end' ), + 'billing_period' => $subscription->get_billing_period(), + 'billing_interval' => $subscription->get_billing_interval(), + 'missed_periods' => 6, + 'missed_total' => 60, + ] + ); + } +} From 9c4ebd08a326481a986f7202181a0ba1ddfb896c Mon Sep 17 00:00:00 2001 From: dkoo Date: Fri, 1 Nov 2024 16:14:32 -0600 Subject: [PATCH 08/11] test: minor assertion improvements --- tests/unit-tests/woocommerce-cli.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/unit-tests/woocommerce-cli.php b/tests/unit-tests/woocommerce-cli.php index 846348750c..4e739aaf4a 100644 --- a/tests/unit-tests/woocommerce-cli.php +++ b/tests/unit-tests/woocommerce-cli.php @@ -104,7 +104,7 @@ public function test_missing_next_payment_date() { ); // Next payment date is now in the future. - $this->assertGreaterThan( time(), strtotime( $result['next_payment_date'] ) ); + $this->assertGreaterThan( time(), strtotime( $subscription->get_date( 'next_payment' ) ) ); } /** @@ -152,7 +152,7 @@ public function test_missing_next_payment_date_with_order() { ); // Next payment date is now in the future. - $this->assertGreaterThan( time(), strtotime( $result['next_payment_date'] ) ); + $this->assertGreaterThan( time(), strtotime( $subscription->get_date( 'next_payment' ) ) ); } /** @@ -174,7 +174,7 @@ public function test_missing_next_payment_date_with_end_date() { ); $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); - // Subscription was processed, but next payment date not set because the end date will occur first.. + // Subscription was processed. $this->assertEquals( $result, [ @@ -189,5 +189,8 @@ public function test_missing_next_payment_date_with_end_date() { 'missed_total' => 60, ] ); + + // Next payment date not set because the end date will occur first. + $this->assertEmpty( $subscription->get_date( 'next_payment' ) ); } } From a18b920e2b9381582110f1c7f425b08c26d5c8fe Mon Sep 17 00:00:00 2001 From: Derrick Koo Date: Mon, 4 Nov 2024 09:47:39 -0700 Subject: [PATCH 09/11] Update tests/unit-tests/woocommerce-cli.php Co-authored-by: Adam Cassis --- tests/unit-tests/woocommerce-cli.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit-tests/woocommerce-cli.php b/tests/unit-tests/woocommerce-cli.php index 4e739aaf4a..dacb990d19 100644 --- a/tests/unit-tests/woocommerce-cli.php +++ b/tests/unit-tests/woocommerce-cli.php @@ -66,7 +66,7 @@ public function test_healthy_subscription() { $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); // Subscription wasn't processed. - $this->assertFalse( $result ); + $this->assertFalse( $result, Subscription wasn't processed. ); } /** From 2603e8af5ad1be806f45be3c1d241bbd6c07769c Mon Sep 17 00:00:00 2001 From: dkoo Date: Mon, 4 Nov 2024 09:51:32 -0700 Subject: [PATCH 10/11] test: update assertions with human-readable messages --- tests/unit-tests/woocommerce-cli.php | 31 +++++++++------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/tests/unit-tests/woocommerce-cli.php b/tests/unit-tests/woocommerce-cli.php index dacb990d19..597af7819b 100644 --- a/tests/unit-tests/woocommerce-cli.php +++ b/tests/unit-tests/woocommerce-cli.php @@ -64,9 +64,7 @@ public function test_healthy_subscription() { ] ); $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); - - // Subscription wasn't processed. - $this->assertFalse( $result, Subscription wasn't processed. ); + $this->assertFalse( $result, 'Healthy subscription wasn’t processed.' ); } /** @@ -86,8 +84,6 @@ public function test_missing_next_payment_date() { ] ); $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); - - // Subscription was processed. $this->assertEquals( $result, [ @@ -100,11 +96,10 @@ public function test_missing_next_payment_date() { 'billing_interval' => $subscription->get_billing_interval(), 'missed_periods' => 6, 'missed_total' => 60, - ] + ], + 'Subscription was processed.' ); - - // Next payment date is now in the future. - $this->assertGreaterThan( time(), strtotime( $subscription->get_date( 'next_payment' ) ) ); + $this->assertGreaterThan( time(), strtotime( $subscription->get_date( 'next_payment' ) ), 'Next payment date is now in the future.' ); } /** @@ -134,8 +129,6 @@ public function test_missing_next_payment_date_with_order() { ] ); $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); - - // Subscription was processed. $this->assertEquals( $result, [ @@ -148,11 +141,10 @@ public function test_missing_next_payment_date_with_order() { 'billing_interval' => $subscription->get_billing_interval(), 'missed_periods' => 3, 'missed_total' => 30, - ] + ], + 'Subscription was processed.' ); - - // Next payment date is now in the future. - $this->assertGreaterThan( time(), strtotime( $subscription->get_date( 'next_payment' ) ) ); + $this->assertGreaterThan( time(), strtotime( $subscription->get_date( 'next_payment' ) ), 'Next payment date is now in the future.' ); } /** @@ -173,8 +165,6 @@ public function test_missing_next_payment_date_with_end_date() { ] ); $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); - - // Subscription was processed. $this->assertEquals( $result, [ @@ -187,10 +177,9 @@ public function test_missing_next_payment_date_with_end_date() { 'billing_interval' => $subscription->get_billing_interval(), 'missed_periods' => 6, 'missed_total' => 60, - ] + ], + 'Subscription was processed.' ); - - // Next payment date not set because the end date will occur first. - $this->assertEmpty( $subscription->get_date( 'next_payment' ) ); + $this->assertEmpty( $subscription->get_date( 'next_payment' ), 'Next payment date not set because the end date will occur first.' ); } } From 879ba1a06c8cd1a6a5c2759c7fb9282c64ab9a86 Mon Sep 17 00:00:00 2001 From: dkoo Date: Mon, 4 Nov 2024 13:34:30 -0700 Subject: [PATCH 11/11] refactor: rename method for clarity --- .../reader-revenue/woocommerce/class-woocommerce-cli.php | 8 +++++--- tests/unit-tests/woocommerce-cli.php | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php index 851da4bb7b..1317e574d3 100644 --- a/includes/reader-revenue/woocommerce/class-woocommerce-cli.php +++ b/includes/reader-revenue/woocommerce/class-woocommerce-cli.php @@ -219,7 +219,7 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { break; } - $result = self::calculate_next_payment_date( $subscription, $dry_run ); + $result = self::validate_subscription_dates( $subscription, $dry_run ); if ( ! $result ) { continue; } @@ -268,14 +268,16 @@ public function fix_missing_next_payment_dates( $args, $assoc_args ) { } /** - * Given a subscription, calculates the next payment date and missed payments. + * Validate renewal date for the given subscription, accounting for end date. + * If missing, calculates the next_payment date and reports missed payments + * since the last successful order or subscription start. * * @param WC_Subscription $subscription The subscription. * @param bool $dry_run If set, will not make any changes. * * @return array|false The result array or false if the subscription is broken. */ - public static function calculate_next_payment_date( $subscription, $dry_run = false ) { + public static function validate_subscription_dates( $subscription, $dry_run = false ) { $now = time(); $subscription_start = $subscription->get_date( 'start' ); $next_payment_date = $subscription->get_date( 'next_payment' ); diff --git a/tests/unit-tests/woocommerce-cli.php b/tests/unit-tests/woocommerce-cli.php index 597af7819b..5acc26e56f 100644 --- a/tests/unit-tests/woocommerce-cli.php +++ b/tests/unit-tests/woocommerce-cli.php @@ -63,7 +63,7 @@ public function test_healthy_subscription() { ], ] ); - $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); + $result = WooCommerce_Cli::validate_subscription_dates( $subscription ); $this->assertFalse( $result, 'Healthy subscription wasn’t processed.' ); } @@ -83,7 +83,7 @@ public function test_missing_next_payment_date() { ], ] ); - $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); + $result = WooCommerce_Cli::validate_subscription_dates( $subscription ); $this->assertEquals( $result, [ @@ -128,7 +128,7 @@ public function test_missing_next_payment_date_with_order() { ], ] ); - $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); + $result = WooCommerce_Cli::validate_subscription_dates( $subscription ); $this->assertEquals( $result, [ @@ -164,7 +164,7 @@ public function test_missing_next_payment_date_with_end_date() { ], ] ); - $result = WooCommerce_Cli::calculate_next_payment_date( $subscription ); + $result = WooCommerce_Cli::validate_subscription_dates( $subscription ); $this->assertEquals( $result, [