Is there an existing way for an end user to cancel their recurring order (i.e. Subscription)? Or is this something that needs to be implemented on a case-by-case basis?

CommentFileSizeAuthor
#20 cl_rules_set_status.jpg40.35 KBjweirather
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

dave bruns’s picture

I'm wondering the same thing. Assuming a monthly subscription product, how does a user see when the license will renew and cancel the renewal / subscription if they like?

If there's nothing in the UI that currently allows cancellation, what exactly needs to happen in code to cause a monthly subscription product to be cancelled, so that the license is revoked and all future billing is terminated?

Note: there is some discussion about scheduling cancellations here: https://www.drupal.org/node/2247703

Jody Lynn’s picture

I believe that on /admin/commerce/licenses/list an admin can change a license status to Revoked to cancel it. End users may just need to be instructed to contact customer service to cancel.

aytee’s picture

Jody Lynn - you are correct. However, I wanted a way for the end user to cancel their own subscription.
I wrote a small module that invoked the rules component to cancel the order, which cancels the subscription

		if($order->status == 'recurring_open' ){
				rules_invoke_component('rules_commerce_order_status_canceled', $order);
		}

Kazanir’s picture

Status: Active » Needs review

Jody is correct for the time being. Everyone's user interface desire around order cancellation is quite different and so an implement-your-own model is probably best for now.

Code-wise, there are 2 facets to this:

1. If a license is set to a status other than COMMERCE_LICENSE_ACTIVE (i.e. revoked or suspended), it will not be renewed onto a new order at the end of a billing cycle. This is the equivalent of cancelling your cell phone plan. Important to note is that if you only do this part, post-paid plans will still be charged for their usage during the current billing cycle when the cycle closes.

2. If you also manually close a billing cycle (i.e. set $cycle->status = 0 and save the cycle), then the order will not be closed properly when the billing cycle ends, and thus will not be charged at all. (Typically we also set the $order->status to canceled when we do this for clarity.) This prevents existing charges on a post-paid plan from being charged, which is useful in situations where a customer has placed an order in error and you don't want to charge them anything at all.

Let me know if this helps or if anyone has additional questions.

capfive’s picture

Can you please elaborate on this aytee? I am in need of user triggered cancellation, where would i put this code? in custom module? does it need a fucntion to run with it?

And does this allow the user to cancel thier order which in turn cancels their subscription?

capfive’s picture

Issue tags: +needs reply
daveparrish’s picture

@capfive

It looks like 'rules_commerce_order_status_canceled' rule comes from commerce_backoffice which you may not have enabled. If I understand @Kazanir's comment correctly, all that needs to be done is to change the relevant COMMERCE_LICENSE_ACTIVE license to COMMERCE_LICENSE_SUSPENDED.

eleuthere’s picture

Hi guys,
I have two questions :
- How do I precisely do to let a user cancel an order renewal ? Which files do I create ?
- How can I help to make that feature standard ?
cu

capfive’s picture

I'm a little new to all this rule stuff, would be great to get a detailed how-to like @eleuthere is asking for :)

i.e do i create a rule in CMS? or is it code in the module?

I think it should be standard that if a user cancel's their account, that the license is revoked/canceled as well, how can you continue to use the license if they do not have an account on the site?

agileadam’s picture

Like others here, I am in need of a way to let users cancel their own licenses.

My plan is to call (with custom code) the rules actions defined in this patch: #2470467: Rules actions for revoke/suspend. More specifically, I'll create a menu callback (hook_menu()) that will handle a few arguments (user id, order number); the callback will perform some validation on the arguments and ask the user to confirm their intention to "cancel" their subscription/license/plan/whatever. If they continue to submit the form, it'll then call the suspend or revoke rules action function.

I'm assuming the rules actions defined in that patch will eventually make it into commerce_license, so I'd rather make use of those (by invoking them) than write my own functions to remove/suspend licenses. It's not that they're complicated, but rather that it'd be good to standardize this a bit. Time will tell where that patch goes...

Anyone have any opinions on this approach?

UPDATE: While thinking about this some more, I realized I actually want to show the "Cancel" link alongside a product (which is a "Plan" in my site; "basic" or "premium" for example), if the user has a license for that product. To accommodate this, my callback will use the user id and the product ID (not the order ID). I'd then be revoking/suspending whatever license exists for the product+user combo (if any). This site only allows a user to maintain a single license at any given time, so I don't have to worry about suspending the wrong license/order.

This is still all just experimentation and exploration, so I'll save you the play-by-play comments unless I come up with something I think would be helpful.

eleuthere’s picture

I don't know how to do this but I like well built and reusable stuff.
I'm writing a tutorial/howto about selling roles with commerce from a "Commons" distribution starting point.
It's here : https://www.drupal.org/node/2482277
I'd be happy to apply patches or test dev versions of any module and complete my tutorial to contribute make thing operational, reliable and clear for anyone to use.
Tell me.

agileadam’s picture

Hey all,

I had to spend some time on this to come up with a quick solution. If I had the time to plan and create a new module that'd be more universal, I would. Unfortunately it's not in the cards for this one. Also, as it's been said before, each person's needs and implementation will probably be quite different.

Anyhow, I threw up a quick blog post to hopefully provide some direction to some of you: http://agileadam.com/2015/05/allowing-users-to-cancel-their-own-commerce...

This is not production-ready code, but check it out.

daveparrish’s picture

@agileadam,

Thank you for the post. I implement a cancel button almost the same way you did for a project I am working on. A solution like that would probably make for a good contrib module. I put my cancel button in the user profile page, but I like replacing the "Add to cart" button with the "Cancel Membership" button.

agileadam’s picture

As I wrote some code yesterday I thought about a contrib module that would provide:

  • A "cancel membership" token for order entities
  • A "cancel membership" token for product entities
  • A "cancel membership" item in the "operations" for orders with membership line items
  • The menu callback and subsequent cancellation form
  • Perhaps some rules events/actions/conditions

If people agreed on what they wanted I'm sure we could come up with something.

eleuthere’s picture

Hi,
Beyond the immediate difficulty, there is site maintainability : what if we have to apply patches after the next module upgrade ? I wrote a small module just to delete an text area in a form. It's not very difficult...if you find examples and explainations...
Selling roles or files or streams is not selling hardware. What if a user byes twice the same license ? Do you refund ? It's useless workload. It could be interesting not to propose a user to bye a license of an already running type.
or for the same product or... I don't know which condition. Buyable products may depends on running licenses. Could it be managed with rules ? The idea to alter the "Add to cart" button to switch the action depending on conditions like running licenses is astucious. Could it be used more generally ?
But maybe this is not the right place to launch a specification talk...

wesjones’s picture

Another non-coding option is to clone the view "Licenses". Then just customize that view for the user to revoke their own licenses.
I removed all the Bulk Operations except revoke. I removed the user field and commerce product field. I removed the filter criteria. I also stole a few ideas from the orders view like the path and contextual filters. Then set the permissions to "View own licenses". I also removed the relationships under advanced.

agileadam’s picture

wesoccer2003, that's an interesting approach. I like it.

agileadam’s picture

Okay folks, I decided it's probably best to create a contrib module (and documentation) for this stuff, just to get the ball rolling.

Please read the documentation and then have a look at the module.

I'll let the documentation explain what I did, rather than re-explain it here.

NOTE: there are plenty of TODOs in the code, which you can browse at http://cgit.drupalcode.org/commerce_license_cancel/commit/?id=523286b

jweirather’s picture

If I'm reading these comments (#4, #7) correctly, we have the option to cancel licenses by setting a data value in rules, where the data value is the "status" of the license?

Commerce License handles it from there?

Although a specific rule would be prettier, the data value route still simplifies the matter for me (versus a whole new custem module, etc).

jweirather’s picture

FileSize
40.35 KB

Expanding on my previous comment: it appears I am able to set the Commerce License status via rules, using "set a data value". See attached image.

In my install a user can have multiple independent licenses, so revoking or suspending all licenses on an order (as in the patch) would be a problem, or require much voodoo.

Assuming "set a data value" works and Commerce License then cancels the recurring order at the appropriate time, I expect I should also be able to use a flag of some kind to fire the rule, like for example, a flag which a user can only flag and not unflag (so it disappears after one-time use). Such a flag would then be exposed to the rest of the UI, views, panels, etc...

I'd have to assume that if these concepts do work they address many use cases?

bjlewis2’s picture

Definitely need a way for a user to cancel their membership as well... I'll checkout the contrib in #18...

jweirather’s picture

Thanks to @agileadam for the forms based contribution.

Also, you may be able to set something up using flag/rules.

Andrew Jamieson’s picture

I have tried the module in #18 and it doesn't seem to work. I get a success message when cancelling the order but they are not being cancelled?

agileadam’s picture

@Andrew,
Throw an issue into the queue for commerce_license_cancel and I'll have a look.

arthur_drupal’s picture

Here is my view for the end user, with a menu link. Hope it can help.

$view = new view();
$view->name = 'mes_abonnements';
$view->description = '';
$view->tag = 'default';
$view->base_table = 'commerce_license';
$view->human_name = 'Mes abonnements';
$view->core = 7;
$view->api_version = '3.0';
$view->disabled = FALSE; /* Edit this to true to make a default view disabled initially */

/* Display: Master */
$handler = $view->new_display('default', 'Master', 'default');
$handler->display->display_options['title'] = 'Licenses';
$handler->display->display_options['use_more_always'] = FALSE;
$handler->display->display_options['use_more_text'] = 'plus';
$handler->display->display_options['access']['type'] = 'role';
$handler->display->display_options['access']['role'] = array(
  2 => '2',
);
$handler->display->display_options['cache']['type'] = 'none';
$handler->display->display_options['query']['type'] = 'views_query';
$handler->display->display_options['exposed_form']['type'] = 'basic';
$handler->display->display_options['exposed_form']['options']['submit_button'] = 'Appliquer';
$handler->display->display_options['exposed_form']['options']['reset_button_label'] = 'Réinitialiser';
$handler->display->display_options['exposed_form']['options']['exposed_sorts_label'] = 'Trier par';
$handler->display->display_options['pager']['type'] = 'full';
$handler->display->display_options['pager']['options']['items_per_page'] = '50';
$handler->display->display_options['pager']['options']['offset'] = '0';
$handler->display->display_options['pager']['options']['id'] = '0';
$handler->display->display_options['pager']['options']['quantity'] = '9';
$handler->display->display_options['pager']['options']['expose']['items_per_page_label'] = 'Éléments par page';
$handler->display->display_options['pager']['options']['expose']['items_per_page_options_all_label'] = '- Tout -';
$handler->display->display_options['pager']['options']['expose']['offset_label'] = 'Décalage';
$handler->display->display_options['pager']['options']['tags']['first'] = '« premier';
$handler->display->display_options['pager']['options']['tags']['previous'] = '‹ précédent';
$handler->display->display_options['pager']['options']['tags']['next'] = 'suivant ›';
$handler->display->display_options['pager']['options']['tags']['last'] = 'dernier »';
$handler->display->display_options['style_plugin'] = 'table';
$handler->display->display_options['style_options']['columns'] = array(
  'license_id' => 'license_id',
  'name' => 'name',
  'title_field' => 'title_field',
  'access_details' => 'access_details',
  'type' => 'type',
  'status' => 'status',
  'granted' => 'granted',
);
$handler->display->display_options['style_options']['default'] = 'license_id';
$handler->display->display_options['style_options']['info'] = array(
  'license_id' => array(
    'sortable' => 1,
    'default_sort_order' => 'desc',
    'align' => '',
    'separator' => '',
    'empty_column' => 0,
  ),
  'name' => array(
    'sortable' => 1,
    'default_sort_order' => 'asc',
    'align' => '',
    'separator' => '',
    'empty_column' => 0,
  ),
  'title_field' => array(
    'sortable' => 1,
    'default_sort_order' => 'asc',
    'align' => '',
    'separator' => '',
    'empty_column' => 0,
  ),
  'access_details' => array(
    'align' => '',
    'separator' => '',
    'empty_column' => 0,
  ),
  'type' => array(
    'sortable' => 1,
    'default_sort_order' => 'asc',
    'align' => '',
    'separator' => '',
    'empty_column' => 0,
  ),
  'status' => array(
    'sortable' => 1,
    'default_sort_order' => 'asc',
    'align' => '',
    'separator' => '',
    'empty_column' => 0,
  ),
  'granted' => array(
    'sortable' => 1,
    'default_sort_order' => 'asc',
    'align' => '',
    'separator' => '',
    'empty_column' => 0,
  ),
);
$handler->display->display_options['style_options']['empty_table'] = TRUE;
/* Comportement en l'absence de résultats: Global : Texte non filtré */
$handler->display->display_options['empty']['area_text_custom']['id'] = 'area_text_custom';
$handler->display->display_options['empty']['area_text_custom']['table'] = 'views';
$handler->display->display_options['empty']['area_text_custom']['field'] = 'area_text_custom';
$handler->display->display_options['empty']['area_text_custom']['empty'] = TRUE;
$handler->display->display_options['empty']['area_text_custom']['content'] = 'No licenses found.';
/* Relation: Commerce License : Propriétaire uid */
$handler->display->display_options['relationships']['owner']['id'] = 'owner';
$handler->display->display_options['relationships']['owner']['table'] = 'commerce_license';
$handler->display->display_options['relationships']['owner']['field'] = 'owner';
/* Relation: Commerce License : Pack product_id */
$handler->display->display_options['relationships']['product']['id'] = 'product';
$handler->display->display_options['relationships']['product']['table'] = 'commerce_license';
$handler->display->display_options['relationships']['product']['field'] = 'product';
/* Champ: Opérations en masse : Commerce License */
$handler->display->display_options['fields']['views_bulk_operations']['id'] = 'views_bulk_operations';
$handler->display->display_options['fields']['views_bulk_operations']['table'] = 'commerce_license';
$handler->display->display_options['fields']['views_bulk_operations']['field'] = 'views_bulk_operations';
$handler->display->display_options['fields']['views_bulk_operations']['vbo_settings']['display_type'] = '0';
$handler->display->display_options['fields']['views_bulk_operations']['vbo_settings']['enable_select_all_pages'] = 1;
$handler->display->display_options['fields']['views_bulk_operations']['vbo_settings']['force_single'] = 0;
$handler->display->display_options['fields']['views_bulk_operations']['vbo_settings']['entity_load_capacity'] = '10';
$handler->display->display_options['fields']['views_bulk_operations']['vbo_operations'] = array(
  'action::commerce_license_activate_action' => array(
    'selected' => 0,
    'postpone_processing' => 0,
    'skip_confirmation' => 0,
    'override_label' => 0,
    'label' => '',
  ),
  'action::system_send_email_action' => array(
    'selected' => 0,
    'postpone_processing' => 0,
    'skip_confirmation' => 0,
    'override_label' => 0,
    'label' => '',
  ),
  'action::views_bulk_operations_script_action' => array(
    'selected' => 0,
    'postpone_processing' => 0,
    'skip_confirmation' => 0,
    'override_label' => 0,
    'label' => '',
  ),
  'action::views_bulk_operations_modify_action' => array(
    'selected' => 0,
    'postpone_processing' => 0,
    'skip_confirmation' => 0,
    'override_label' => 0,
    'label' => '',
    'settings' => array(
      'show_all_tokens' => 1,
      'display_values' => array(
        '_all_' => '_all_',
      ),
    ),
  ),
  'action::views_bulk_operations_argument_selector_action' => array(
    'selected' => 0,
    'skip_confirmation' => 0,
    'override_label' => 0,
    'label' => '',
    'settings' => array(
      'url' => '',
    ),
  ),
  'action::commerce_license_renew_action' => array(
    'selected' => 0,
    'postpone_processing' => 0,
    'skip_confirmation' => 0,
    'override_label' => 0,
    'label' => '',
  ),
  'action::commerce_license_revoke_action' => array(
    'selected' => 1,
    'postpone_processing' => 0,
    'skip_confirmation' => 0,
    'override_label' => 0,
    'label' => '',
  ),
  'action::views_bulk_operations_delete_item' => array(
    'selected' => 0,
    'postpone_processing' => 0,
    'skip_confirmation' => 0,
    'override_label' => 1,
    'label' => 'Delete license',
  ),
  'action::views_bulk_operations_delete_revision' => array(
    'selected' => 0,
    'postpone_processing' => 0,
    'skip_confirmation' => 0,
    'override_label' => 0,
    'label' => '',
  ),
  'action::commerce_license_suspend_action' => array(
    'selected' => 0,
    'postpone_processing' => 0,
    'skip_confirmation' => 0,
    'override_label' => 0,
    'label' => '',
  ),
);
/* Champ: Commerce Product : Titre */
$handler->display->display_options['fields']['title']['id'] = 'title';
$handler->display->display_options['fields']['title']['table'] = 'commerce_product';
$handler->display->display_options['fields']['title']['field'] = 'title';
$handler->display->display_options['fields']['title']['relationship'] = 'product';
$handler->display->display_options['fields']['title']['label'] = 'Product';
$handler->display->display_options['fields']['title']['link_to_product'] = 0;
/* Champ: Commerce License : Accordé(e) */
$handler->display->display_options['fields']['granted']['id'] = 'granted';
$handler->display->display_options['fields']['granted']['table'] = 'commerce_license';
$handler->display->display_options['fields']['granted']['field'] = 'granted';
$handler->display->display_options['fields']['granted']['date_format'] = 'medium';
/* Champ: Commerce License : Expire */
$handler->display->display_options['fields']['expires']['id'] = 'expires';
$handler->display->display_options['fields']['expires']['table'] = 'commerce_license';
$handler->display->display_options['fields']['expires']['field'] = 'expires';
$handler->display->display_options['fields']['expires']['date_format'] = 'medium';
/* Filtre contextuel: Commerce License : Propriétaire uid */
$handler->display->display_options['arguments']['owner']['id'] = 'owner';
$handler->display->display_options['arguments']['owner']['table'] = 'commerce_license';
$handler->display->display_options['arguments']['owner']['field'] = 'owner';
$handler->display->display_options['arguments']['owner']['default_action'] = 'default';
$handler->display->display_options['arguments']['owner']['exception']['title'] = 'Tout';
$handler->display->display_options['arguments']['owner']['default_argument_type'] = 'current_user';
$handler->display->display_options['arguments']['owner']['summary']['number_of_records'] = '0';
$handler->display->display_options['arguments']['owner']['summary']['format'] = 'default_summary';
$handler->display->display_options['arguments']['owner']['summary_options']['items_per_page'] = '25';
/* Critère de filtrage: Commerce License : Statut */
$handler->display->display_options['filters']['status']['id'] = 'status';
$handler->display->display_options['filters']['status']['table'] = 'commerce_license';
$handler->display->display_options['filters']['status']['field'] = 'status';
$handler->display->display_options['filters']['status']['value'] = array(
  2 => '2',
);

/* Display: Page */
$handler = $view->new_display('page', 'Page', 'page_1');
$handler->display->display_options['path'] = 'admin/commerce/licenses/list';
$handler->display->display_options['menu']['type'] = 'normal';
$handler->display->display_options['menu']['title'] = 'Mes abonnements';
$handler->display->display_options['menu']['weight'] = '0';
$handler->display->display_options['menu']['name'] = 'menu-my-account';
$handler->display->display_options['menu']['context'] = 0;
$handler->display->display_options['menu']['context_only_inline'] = 0;
$handler->display->display_options['tab_options']['type'] = 'normal';
$handler->display->display_options['tab_options']['title'] = 'Licenses';
$handler->display->display_options['tab_options']['description'] = 'Manage licenses in the store.';
$handler->display->display_options['tab_options']['weight'] = '0';
$handler->display->display_options['tab_options']['name'] = 'management';
$translatables['mes_abonnements'] = array(
  t('Master'),
  t('Licenses'),
  t('plus'),
  t('Appliquer'),
  t('Réinitialiser'),
  t('Trier par'),
  t('Asc'),
  t('Desc'),
  t('Éléments par page'),
  t('- Tout -'),
  t('Décalage'),
  t('« premier'),
  t('‹ précédent'),
  t('suivant ›'),
  t('dernier »'),
  t('No licenses found.'),
  t('Utilisateur'),
  t('Commerce Product'),
  t('Commerce License'),
  t('- Choisir une opération -'),
  t('Delete license'),
  t('Product'),
  t('Accordé(e)'),
  t('Expire'),
  t('Tout'),
  t('Page'),
);
arthur_drupal’s picture

I had to implement this hook in order to let end user "view" own license :

/**
 * Implements of hook_commerce_entity_access_condition_commerce_product_alter
 */
function MY_MODULE_commerce_commerce_entity_access_condition_commerce_product_alter(&$conditions, $context) {
  if($context['base_table'] == 'commerce_product_commerce_license'){
    $conditions->condition('commerce_license.uid', $context['account']->uid);
  }
}
loudpixels’s picture

Hi arthur_drupal,

I would also like to show my users their license ... possible directly on their account page.

Could you please send additional information about implementing your hook?

Excuse my ignorance but how do I use this code? where to save it?

Any pointers would be a great help! Thanks!

wizonesolutions’s picture

Status: Needs review » Closed (works as designed)

I'm gonna close this as working as designed — people need to make their own implementations of this. I'll be sharing one when I am done with it.

A note to people who come here because they have scheduled a cancellation in code and it's not actually changing the license, but it is removing the billing cycle — don't use COMMERCE_LICENSE_REVOKED in code. I suggest COMMERCE_LICENSE_EXPIRED. If you use COMMERCE_LICENSE_REVOKED, commerce_license_billing_collect_charges() will exclude the license outright, and the license will get removed from the order when it refreshes (and it does this prior to renewal). Basically, things will get into a messed-up state, and the user will still have an active license without any recurring order or billing cycle to ensure payment, expiration, etc.