Skip to content
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

fix: command to fix active subs w/ missing next_payment dates #3484

Merged
merged 11 commits into from
Nov 4, 2024
182 changes: 182 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,188 @@ 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::calculate_next_payment_date( $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( '' );
}

/**
* 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 ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the function name suggests the the return value is a date. Could be something along the lines of validate_subscription_dates.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to validate_subscription_dates in 879ba1a

$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