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

allow importing cancelled date, and fix handling of end_date #278

Merged
merged 7 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ To import your subscriptions to WooCommerce via CSV, you need to:
1. Check **Email Passwords** to email customers that are newly created their account details
1. Check **Add memberships** to grant subscribers any membership/s plan corresponding to subscription products
1. Click **Upload and file and import**
1. Review each column of data in your file to make sure the Importer has mapped it to the correct column header.
1. Review each column of data in your file to make sure the Importer has mapped it to the correct column header.
1. Click **Test CSV**
1. If there are errors, fix up the CSV file and return to step 1.
1. If there are no errors, click **Run Import**
Expand All @@ -70,7 +70,7 @@ Import options:
![](https://cldup.com/YFwi6NIp-L.png)

#### Run in Test Mode
Running the import in test mode will analyse each row of your CSV and notify you of any [warnings or errors](#list-of-warnings-and-errors) with that data.
Running the import in test mode will analyse each row of your CSV and notify you of any [warnings or errors](#list-of-warnings-and-errors) with that data.

It will not import any subscription data in your store's database, like users or subscriptions.

Expand All @@ -90,7 +90,7 @@ When the **Email Passwords** option is enabled, if the Importer creates a new us

If left unticked, the new users created will need to go through the "forgot your password" process which will let them reset their details via email.

Please note: the minimum requirement for creating a new user is an email address. If no username is given, the importer will to create a username from the email. Say you you need to create a new user and have only given the email address, janedoe@example.com, the importer will try a new user with username janedoe. If this username is already taken, we then try the username janedoe1, janedoe2 and so on; until it finds a free username (i.e janedoe102).
Please note: the minimum requirement for creating a new user is an email address. If no username is given, the importer will to create a username from the email. Say you you need to create a new user and have only given the email address, janedoe@example.com, the importer will try a new user with username janedoe. If this username is already taken, we then try the username janedoe1, janedoe2 and so on; until it finds a free username (i.e janedoe102).

#### Add Memberships

Expand Down Expand Up @@ -247,7 +247,8 @@ Please follow these general rules when formatting your CSV file:
|`start_date`|`Y-m-d H:i:s`|The start time to set on the subscription. Must be in the past.|The current time.|
|`trial_end_date`|`Y-m-d H:i:s`|A date in the past or future on which a the subscriptions trial period will end. If set, the trial end date must come after the start date.|-|
|`next_payment_date`|`Y-m-d H:i:s`|The date to process the next renewal payment. If set, the next payment date must come after the start date and trial end date and be in the future. If left empty, when the status is next updated to `wc-active` the next payment date will be calculated based on the start or trial end date and billing period/interval.|-|
|`end_date`|`Y-m-d H:i:s`|The date on which the subscription will expire, if in the future, or was cancelled or expired, if in the past. Leave empty to have the subscription continue to renew until manually cancelled.|-|
|`cancelled_date`|`Y-m-d H:i:s`|The date on which the subscription was set to be cancelled, either by the customer or via admin action. If setting this date, the subscription status must be one of the following: `cancelled`, `trash`, `expired`, `switched`, `pending-cancel`. Leave empty if the subscription was never set to be cancelled.|-|
|`end_date`|`Y-m-d H:i:s`|The date on which the subscription will cancel/expire, if in the future, or did cancel/expire, if in the past. Leave empty to have the subscription continue to renew until manually cancelled. If setting this with `cancelled_date`, the `cancelled_date` must occur before `end_date`. If setting this without `cancelled_date`, the `cancelled_date` will be set to the same time as `end_date`.|-|
|`billing_period`|`string`|The time period used for calculating renewal payment dates. Must be either: `day`, `week`, `month`, `year`. An invalid or empty billing period will cause an error during the import and the subscription will not be imported.|-|
|`billing_interval`|`int`|The interval used for calculating renewal payment dates. Must be an integer value to represent how many subscription periods between each payment. For example, a `2` here and `week` for the `billing_period` will create a subscription processes a renewal payment every two weeks.|`1`|
|`order_items`|`mixed`|The product line items on the subscription used to set the line items on renewal orders. Can be a product or variation ID or a more advanced set of data as detailed in the [Importing Order Items](#importing-order-items-product-line-items) section.|-|
Expand Down Expand Up @@ -497,7 +498,7 @@ If the later approach is taken, we strongly recommend that you notify customers
> PayPal Standard Note: Unfortunately, this approach isn't suitable for PayPal Standard. With PayPal Standard, if no valid PayPal subscription ID is provided, exceptions will be thrown whenever you or the customer changes the status of the subscription with your store, meaning its impossible to change the status of imported subscriptions. This is because Subscriptions attempts to communicate this state change to PayPal, but is unable to connect it to a valid subscription there. If you need to take this approach for customers using PayPal Standard, temporarily use Stripe or a fake payment method instead.

#### How can I check if a payment method can be imported with automatic payments?
WooCommerce Subscriptions v2.0 introduced a new way for payment gateways to register the payment meta data they require for processing automatic recurring payments.
WooCommerce Subscriptions v2.0 introduced a new way for payment gateways to register the payment meta data they require for processing automatic recurring payments.

To support this method, the payment gateway extension must use the filter: `'woocommerce_subscription_payment_meta'`.

Expand Down
2 changes: 1 addition & 1 deletion includes/class-wcs-exporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ public static function write_subscriptions_csv_row( $subscription ) {
if ( ! empty( $meta_string ) ) {
$meta_string .= '+';
}

// Prevent array to string notice caused by Composite Products when using Subscribe All The Things
if ( is_array( $meta_value ) ) {
$meta_value = json_encode( $meta_value );
Expand Down
3 changes: 2 additions & 1 deletion includes/class-wcs-import-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ private function mapping_page() {
$row_number = 1;

$customer_fields = array( 'customer_id', 'customer_email', 'customer_username', 'customer_password' );
$subscription_fields = array( 'start_date', 'next_payment_date', 'cancelled_date', 'end_date', 'trial_end_date', 'last_payment_date', 'billing_interval', 'billing_period' );
$subscription_fields = array( 'start_date', 'next_payment_date', 'cancelled_date', 'cancelled_date', 'end_date', 'trial_end_date', 'last_payment_date', 'billing_interval', 'billing_period' );
dkoo marked this conversation as resolved.
Show resolved Hide resolved
?>

<h3><?php esc_html_e( 'Step 2: Map Fields to Column Names', 'wcs-import-export' ); ?></h3>
Expand Down Expand Up @@ -462,6 +462,7 @@ public function save_mapping() {
'trial_end_date' => '',
'next_payment_date' => '',
'last_payment_date' => '',
'cancelled_date' => '',
'end_date' => '',
'billing_first_name' => '',
'billing_last_name' => '',
Expand Down
49 changes: 34 additions & 15 deletions includes/class-wcs-importer.php
Original file line number Diff line number Diff line change
Expand Up @@ -310,16 +310,16 @@ public static function import_subscription( $data ) {
}
}

$status = 'pending';
if ( empty( $data[ self::$fields['subscription_status'] ] ) ) {
$status = 'pending';
$result['warning'][] = esc_html__( 'No subscription status was specified. The subscription will be created with the status "pending". ', 'wcs-import-export' );
} else {
$status = ( 'wc-' === substr( $data[ self::$fields['subscription_status'] ], 0, 3 ) ) ? substr( $data[ self::$fields['subscription_status'] ], 3 ) : $data[ self::$fields['subscription_status'] ];
}

$dates_to_update = array( 'start' => ( ! empty( $data[ self::$fields['start_date'] ] ) ) ? gmdate( 'Y-m-d H:i:s', strtotime( $data[ self::$fields['start_date'] ] ) ) : gmdate( 'Y-m-d H:i:s', time() - 1 ) );

foreach ( array( 'trial_end_date', 'next_payment_date', 'end_date', 'last_payment_date' ) as $date_type ) {
foreach ( array( 'trial_end_date', 'next_payment_date', 'cancelled_date', 'end_date', 'last_payment_date' ) as $date_type ) {
$dates_to_update[ $date_type ] = ( ! empty( $data[ self::$fields[ $date_type ] ] ) ) ? gmdate( 'Y-m-d H:i:s', strtotime( $data[ self::$fields[ $date_type ] ] ) ) : '';
}

Expand All @@ -330,16 +330,29 @@ public static function import_subscription( $data ) {
}

switch ( $date_type ) {
case 'cancelled_date':
if ( 'cancelled_date' === $date_type && ! in_array( $status, wcs_get_subscription_ended_statuses() ) ) {
$result['error'][] = sprintf( __( 'Cannot set a %s date for an active subscription.', 'wcs-import-export' ), $date_type );
}
case 'end_date' :
if ( ! empty( $dates_to_update['next_payment_date'] ) && strtotime( $datetime ) <= strtotime( $dates_to_update['next_payment_date'] ) ) {
if ( 'end_date' === $date_type && ! empty( $dates_to_update['next_payment_date'] ) && strtotime( $datetime ) <= strtotime( $dates_to_update['next_payment_date'] ) ) {
$result['error'][] = sprintf( __( 'The %s date must occur after the next payment date.', 'wcs-import-export' ), $date_type );
}
if ( 'end_date' === $date_type && ! empty( $dates_to_update['cancelled_date'] ) && strtotime( $datetime ) <= strtotime( $dates_to_update['cancelled_date'] ) ) {
$result['error'][] = sprintf( __( 'The %s date must occur after the cancelled date.', 'wcs-import-export' ), $date_type );
}

if ( 'end_date' === $date_type && empty( $dates_to_update['cancelled_date'] ) ) {
$dates_to_update['cancelled_date'] = $datetime;
ksort( $dates_to_update ); // Sort so that `cancelled_date` is processed before `end_date`, otherwise updating dates will trigger a fatal.
$result['warning'][] = sprintf( __( 'Setting %1$s date requires a cancelled date. Setting the cancelled date to %2$s.', 'wcs-import-export' ), $date_type, $datetime );
}
james-allan marked this conversation as resolved.
Show resolved Hide resolved
case 'next_payment_date' :
if ( ! empty( $dates_to_update['trial_end_date'] ) && strtotime( $datetime ) < strtotime( $dates_to_update['trial_end_date'] ) ) {
if ( 'next_payment_date' === $date_type && ! empty( $dates_to_update['trial_end_date'] ) && strtotime( $datetime ) < strtotime( $dates_to_update['trial_end_date'] ) ) {
$result['error'][] = sprintf( __( 'The %s date must occur after the trial end date.', 'wcs-import-export' ), $date_type );
}
case 'trial_end_date' :
if ( strtotime( $datetime ) <= strtotime( $dates_to_update['start'] ) ) {
if ( 'trial_end_date' === $date_type && strtotime( $datetime ) <= strtotime( $dates_to_update['start'] ) ) {
$result['error'][] = sprintf( __( 'The %s must occur after the start date.', 'wcs-import-export' ), $date_type );
Copy link
Contributor

Choose a reason for hiding this comment

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

IIRC this switch statement purposefully doesn't have break statements and it is expected to flow through the lower cases. eg cancelled being the top case means that the cancelled date is also validated against the end date, next payment and trial end checks. ie the cancelled date must occur after the next payment date (which is a condition under the end date case).

I've never been a fan of this code though as it's super confusing, hard to follow and is not intuitive at all. I wonder if the csv importer would benefit just not validating the dates relationships at all? 🤔

It calls $subscription->update_dates() which validates them anyway and throws exceptions if they don't pass the validation rules.

The csv importer/exporter calls update_dates in a try catch block so would be able to catch those exceptions and handle them appropriately anyway.

Copy link
Contributor

Choose a reason for hiding this comment

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

I just realised that removing this section would essentially break an important part of test mode as folks would use test mode to verify that the dates are valid and update_dates() isn't called while in test mode. 🤔

hmm. How important were these changes for the goal of this PR? It seems from the PR description that you were mostly set on allowing users to set cancelled dates and for the end date issue with regard to when the status is set.

So, can these proposed changes be removed from this PR and we can look into doing a bit more of an overhaul of the validation of dates at a later time?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@james-allan you're right—these changes are a bit overzealous. I've reduced their scope to only add validation for checking cancelled_date vs. end_date, so the behavior for validating other dates should be intact.

}
}
Expand Down Expand Up @@ -367,6 +380,9 @@ public static function import_subscription( $data ) {
}
}

add_filter( 'woocommerce_can_subscription_be_updated_to_cancelled', '__return_true' );
add_filter( 'woocommerce_can_subscription_be_updated_to_pending-cancel', '__return_true' );

$subscription = wcs_create_subscription( array(
'customer_id' => $user_id,
'start_date' => $dates_to_update['start'],
Expand All @@ -375,9 +391,13 @@ public static function import_subscription( $data ) {
'created_via' => 'importer',
'customer_note' => ( ! empty( $data[ self::$fields['customer_note'] ] ) ) ? $data[ self::$fields['customer_note'] ] : '',
'currency' => ( ! empty( $data[ self::$fields['order_currency'] ] ) ) ? $data[ self::$fields['order_currency'] ] : '',
'status' => $status,
)
);

remove_filter( 'woocommerce_can_subscription_be_updated_to_cancelled', '__return_true' );
remove_filter( 'woocommerce_can_subscription_be_updated_to_pending-cancel', '__return_true' );

if ( is_wp_error( $subscription ) ) {
throw new Exception( sprintf( esc_html__( 'Could not create subscription: %s', 'wcs-import-export' ), $subscription->get_error_message() ) );
}
Expand Down Expand Up @@ -406,6 +426,7 @@ public static function import_subscription( $data ) {
// Now that we've set all the meta data, reinit the object so the data is set
$subscription = wcs_get_subscription( $subscription_id );

// Set dates.
$subscription->update_dates( $dates_to_update );

if ( ! $set_manual && ! in_array( $status, wcs_get_subscription_ended_statuses() ) ) { // don't bother trying to set payment meta on a subscription that won't ever renew
Expand Down Expand Up @@ -491,22 +512,12 @@ public static function import_subscription( $data ) {
}

if ( ! self::$test_mode ) {
add_filter( 'woocommerce_can_subscription_be_updated_to_cancelled', '__return_true' );
add_filter( 'woocommerce_can_subscription_be_updated_to_pending-cancel', '__return_true' );

$subscription->update_status( $status );

remove_filter( 'woocommerce_can_subscription_be_updated_to_cancelled', '__return_true' );
remove_filter( 'woocommerce_can_subscription_be_updated_to_pending-cancel', '__return_true' );

if ( self::$add_memberships ) {
foreach ( $order_items as $product_id ) {
self::maybe_add_memberships( $user_id, $subscription->get_id(), $product_id );
}
}
}

if ( ! self::$test_mode ) {
$subscription->save();
}

Expand All @@ -529,6 +540,14 @@ public static function import_subscription( $data ) {
$result['status'] = 'failed';
WCS_Import_Logger::log( sprintf( 'Row #%s failed: %s', $result['row_number'], print_r( $result['error'], true ) ) );
}

/**
* Action hook to allow for custom actions after a subscription has been imported and can be manipulated.
*
* @param WC_Subscription $subscription The subscription object created by the importer.
* @param array $result The result of the import.
*/
do_action( 'woocommerce_subscription_imported_via_csv', $subscription, $result );
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
do_action( 'woocommerce_subscription_imported_via_csv', $subscription, $result );
do_action( 'woocommerce_subscription_imported_via_csv', $subscription, $result, $data );

Passing the data variable (the csv row) might assist with folks who have custom data that they would like to set.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great idea—added in 4bba344

}

array_push( self::$results, $result );
Expand Down