Allowing Users to Cancel Their Own Commerce Licenses
UPDATE: I’ve written a contrib module called Commerce License Cancel to get the ball rolling to make this a community effort. Read on if you’d like to see how I got there.
There’s been some talk in the issue queue for Commerce License Billing about needing a way for users to cancel their own licenses. Here are some pieces and parts that I’m using to achieve this. This is a work in progress and I’m really just posting here so I can provide a clean link in the issue queue. Sorry for the lack of detailed explanations; hopefully you still find this helpful.
There’s already a method to “revoke” a user license. When you revoke a license, the recurring billing should close out automatically and the role granted (if it’s a licensed role) will be taken away from the user.
The site I’m working on has just a few “membership plans” or levels that a user can purchase. For this reason, I could hardcode a few things. It’d take a LOT more work to make this stuff ready for the masses.
Providing the cancellation confirmation form
I chose to create a menu callback that renders a simple form. The URL (http://www.mysite.com/plans/cancel/[productid_here]) allows for a product ID because I want the user to cancel a license that’s associated with a specific product.
Here’s what that looks like (work in progress, of course):
First, I created a permission to make sure only specific roles can cancel their own memberships:
1 2 3 4 5 6 7 8 9 10 11 |
/** * Implements hook_permission(). */ function mymodule_permission() { return array( 'cancel own membership' => array( 'title' => t('Cancel own membership'), 'description' => t('Cancel own membership (commerce license)'), ), ); } |
Here’s the hook_menu implementation:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
/** * Implements hook_menu(). */ function mymodule_menu() { $items = array(); $items['plans/cancel/%'] = array( 'page callback' => 'mymodule_cancel_membership', 'page arguments' => array(2), 'access callback' => 'user_access', 'access arguments' => array('cancel own membership'), 'type' => MENU_NORMAL_ITEM, ); return $items; } |
Here’s the form definition and its validation/submission callbacks:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
/** * * Menu callback for the Cancel Membership page * * @param int $product_id * Commerce product ID * * @return string * HTML for the "Cancel Membership" page */ function mymodule_cancel_membership($product_id) { $output = ''; drupal_set_title(t('Cancel Membership')); $product = commerce_product_load($product_id); if ($product && commerce_license_exists($product)) { // Pass the product object to the form too, because we'll need it during submission processing $form = drupal_get_form('mymodule_cancel_membership_form', $product); $output .= drupal_render($form); } else { drupal_access_denied(); } return $output; } /** * Cancel Membership form */ function mymodule_cancel_membership_form($form, &$form_state, $product) { $form['disclaimer'] = array( '#markup' => t('Some legal copy will go here, probably as an editable Bean block though.'), ); $form['confirm'] = array( '#type' => 'checkbox', '#title' => t('I have read and understand the implications of cancelling my membership.'), ); // We've already looked this up, and we'll need it again, so pass it along $form['product'] = array( '#type' => 'value', '#value' => $product, ); $form['submit'] = array( '#type' => 'submit', '#value' => t('Cancel Membership'), ); return $form; } /** * Validation callback for the Cancel Membership form */ function mymodule_cancel_membership_form_validate($form, &$form_state) { if (!($form_state['values']['confirm'] === 1)) { form_set_error('confirm', t('Please confirm that you understand the implications of cancelling your membership.')); } } /** * Submit callback for the Cancel Membership form */ function mymodule_cancel_membership_form_submit($form, &$form_state) { global $user; $query = new EntityFieldQuery; $query->entityCondition('entity_type', 'commerce_license') ->propertyCondition('status', COMMERCE_LICENSE_ACTIVE) ->propertyCondition('product_id', $form_state['values']['product']->product_id) ->propertyCondition('uid', $user->uid); $result = $query->execute(); if (!empty($result['commerce_license'])) { foreach ($result['commerce_license'] as $license_obj) { $license_ent = entity_load_single('commerce_license', $license_obj->license_id); $license_ent->revoke(); // Assuming this is a "role" license, the user's role will be auto-removed via CommerceLicenseRole->save() $cancelled = TRUE; } } if (isset($cancelled)) { drupal_set_message(t('Your membership was cancelled.')); drupal_goto('plans'); } else { drupal_set_message(t('There was a problem cancelling your membership. Please contact the site administrator.', 'error')); } } |
Rendering the “Cancel membership” link
To get the user to that cancellation form I actually override the Add to Cart form for the membership products so that if the user already has the license for the product, they can cancel it instead of add it to their cart. Two birds with one stone, right?
I have two membership products (monthly recurring and yearly recurring) shown under a single product display. As I said, this site really only has a few products, so some things are hardcoded (like MEMBERSHIP_PREMIUM_MONTHLY, which is just an integer of “7” (the product ID for that product)).
I’m probably going to have to clean this up a bit, but it works for now.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
/** * Implements hook_form_FORM_ID_alter(). */ function mymodule_form_commerce_cart_add_to_cart_form_alter(&$form, &$form_state, $form_id) { if (!empty($form['line_item_fields']['commerce_license'])) { // Handle the Varsity Club plan if (isset($form['product_id']['#options'][MEMBERSHIP_PREMIUM_MONTHLY]) && isset($form['product_id']['#options'][MEMBERSHIP_PREMIUM_YEARLY])) { $premium_monthly = (object) array('product_id' => MEMBERSHIP_PREMIUM_MONTHLY); $premium_yearly = (object) array('product_id' => MEMBERSHIP_PREMIUM_YEARLY); if (commerce_license_exists($premium_monthly)) { $current_plan_product_id = MEMBERSHIP_PREMIUM_MONTHLY; } elseif (commerce_license_exists($premium_yearly)) { $current_plan_product_id = MEMBERSHIP_PREMIUM_YEARLY; } $form['submit']['#attributes']['value'] = t('Upgrade'); } if (isset($current_plan_product_id)) { // Remove any form elements that are included in the DOM output; just show "Current Plan" unset($form['product_id'], $form['line_item_fields']); unset($form['submit'], $form['form_build_id'], $form['form_token'], $form['form_id']); // Add a link to cancel the membership plan $cancel_plan_link = l(t('Cancel Plan'), 'plans/cancel/' . $current_plan_product_id); $form['current_plan'] = array('#markup' => '<span class="downgrade button">' . $cancel_plan_link . '</span>'); } } } |
One Comment
Tim
Does this also cancel the recurring payment?