Skip to content

Commit

Permalink
fix: command to fix active subs w/ missing next_payment dates (#3484)
Browse files Browse the repository at this point in the history
* fix: command to fix active subs w/ missing next_payment dates

* fix: don't process pending-cancel subs

* fix: check for billing period + interval, and account for in-progress

* fix: skip broken subscriptions

* Update includes/reader-revenue/woocommerce/class-woocommerce-cli.php

Co-authored-by: Adam Cassis <adam@adamcassis.com>

* fix: account for successful orders and end dates

* test: add unit tests

* test: minor assertion improvements

* Update tests/unit-tests/woocommerce-cli.php

Co-authored-by: Adam Cassis <adam@adamcassis.com>

* test: update assertions with human-readable messages

* refactor: rename method for clarity

---------

Co-authored-by: Adam Cassis <adam@adamcassis.com>
  • Loading branch information
dkoo and adekbadek authored Nov 4, 2024
1 parent d889162 commit 2e05fd4
Show file tree
Hide file tree
Showing 3 changed files with 451 additions and 0 deletions.
184 changes: 184 additions & 0 deletions includes/reader-revenue/woocommerce/class-woocommerce-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,190 @@ 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=<batch-size>]
* : The number of subscriptions to process in each batch. Default: 50.
*
* [--start-date=<date-string>]
* : 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 );

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...
'
);

$query_args = [
'subscriptions_per_page' => $batch_size,
'subscription_status' => [ 'active', 'pending' ],
'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 );

// If the subscription start date is before the $args start date, we're done.
if ( strtotime( $subscription->get_date( 'start' ) ) < $start_date ) {
$subscriptions = [];
break;
}

$result = self::validate_subscription_dates( $subscription, $dry_run );
if ( ! $result ) {
continue;
}

if ( $result['missed_periods'] ) {
$total_revenue += $result['missed_total'];
}

$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',
'status',
'start_date',
'next_payment_date',
'end_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( '' );
}

/**
* 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 validate_subscription_dates( $subscription, $dry_run = false ) {
$now = time();
$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;

// 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_date = strtotime( $result['end_date'] );
}

while ( $min_date <= $end_date ) {
$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' );
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;
}

/**
* Outputs a list of subscription in CLI
*
Expand Down
82 changes: 82 additions & 0 deletions tests/mocks/wc-mocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ) {
Expand Down
Loading

0 comments on commit 2e05fd4

Please sign in to comment.