Commerce License is in alpha. That means it is not generally ready for production use.
Further, Commerce Recurring is in beta, but the module page itself notes, as of January 2021:
This module is not production ready! Expect pain.
Using these modules now will require writing custom code and debugging; there is no out-of-the-box easy solution at this time.
However, I needed a license solution today, so I decided to implement one in Commerce without using these modules. Here’s a rough sketch of how I did it. Note that I am renewing the subscriptions manually, which is prone to human error; the following flow only provides a UI for users to purchase and configure the period of their subscriptions.
However, this example will hopefully give you an idea of the large amount of work involved in getting subscriptions set up. It’s not easy; if you want something simpler but still not easy, and in which the subscriptions actually recur properly, you could look at Recurly or other SaaS.
- Add a role, Premium user. Give this role the additional permissions that users will purchase.
- Add a datetime field to the user account, Subscription expire date. Add another list text field to the user account, Subscription type.
- Add custom code to restrict permission for the Subscription expire date field to admins only.
- Configure your store + checkout flow in Commerce.
- Create a product type “My license.” Add product variations “One year autorenew,” “One month,” etc.
- Add an EventSubscriber to Commerce to automatically set the user account field Subscription expire date when the purchase is complete. Also, add a redirect to skip the cart because for individual users, having a cart makes no sense for licenses + I don’t want users to purchase multiple subscriptions.
Skipping carts requires this patch in composer.json
:
"drupal/commerce": {
"allow order types to have no carts": "https://www.drupal.org/files/issues/2020-10-05/2810723-130.patch"
},
Even with all this effort, you will still have to renew the subscriptions manually. I did this by adding a view for the admin of “expiring subscriptions” and then using the token stored in the user account by Commerce Stripe to make the recurring payment.
Custom EventSubscriber
Notes about this code: It includes an additional field, a list text field on the user account to let the users change between different subscription types. This allows members to change from a monthly to an annual subscription, or vice versa.
It also assumes that “monthly” subscriptions are always 30 days.
First, services.yml:
services:
mymodule_subscriber:
class: DrupalMYMODULEEventSubscriberMyModuleEventSubscriber
arguments: ('@request_stack', '@logger.factory', '@datetime.time')
tags:
- { name: 'event_subscriber' }
And here’s the Event Subscriber from mymodule/src/EventSubscriber/MyModuleEventSubscriber.php:
<?php
namespace DrupalMYMODULEEventSubscriber;
use DrupalComponentDatetimeTimeInterface;
use DrupalCoreLoggerLoggerChannelFactory;
use DrupaldatetimePluginFieldFieldTypeDateTimeItemInterface;
use Drupalstate_machineEventWorkflowTransitionEvent;
use SymfonyComponentEventDispatcherEventSubscriberInterface;
use Drupalcommerce_cartEventCartEntityAddEvent;
use Drupalcommerce_cartEventCartEvents;
use DrupalCoreUrl;
use SymfonyComponentHttpFoundationRedirectResponse;
use SymfonyComponentHttpFoundationRequestStack;
use SymfonyComponentHttpKernelKernelEvents;
use SymfonyComponentHttpKernelEventFilterResponseEvent;
/*
* Event Subscriber MyModuleEventSubscriber.
*/
class MyModuleEventSubscriber implements EventSubscriberInterface {
/**
* The request stack.
*
* @var SymfonyComponentHttpFoundationRequestStack
*/
protected $requestStack;
/**
* Logger factory.
*
* @var DrupalCoreLoggerLoggerChannelFactory
*/
protected $loggerFactory;
/**
* The time service.
*
* @var DrupalComponentDatetimeTimeInterface
*/
protected $time;
/**
* MyModuleEventSubscriber constructor.
*
* @param SymfonyComponentHttpFoundationRequestStack $request_stack
* The request stack.
* @param DrupalCoreLoggerLoggerChannelFactory $logger_factory
* The logger factory.
* @param DrupalComponentDatetimeTimeInterface $time
* The time service.
*/
public function __construct(RequestStack $request_stack, LoggerChannelFactory $logger_factory, TimeInterface $time) {
$this->requestStack = $request_stack;
// Specify that logs should be for this module.
$this->loggerFactory = $logger_factory->get('mymodule');
$this->time = $time;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events = (
'commerce_order.place.post_transition' => 'postPlaceTransition',
'mymodule_order.fulfill.post_transition' => 'onFulfillTransition',
CartEvents::CART_ENTITY_ADD => 'tryJumpToCheckout',
KernelEvents::RESPONSE => ('checkRedirectIssued', -10),
);
return $events;
}
/**
* Set fields and roles for user accounts that purchase subscriptions.
*/
public function postPlaceTransition(WorkflowTransitionEvent $event) {
$order = $event->getEntity();
$order_type = $order->bundle();
if ($order_type === 'MYORDERTYPE') {
$price_monthly = 500;
$price_annual = 5000;
$subscription_monthly = 'monthly';
$subscription_annual = 'annual';
$customer = $order->getCustomer();
$total_price_array = $order->get('total_price')->getValue();
$total_price = intval(round($total_price_array(0)('number')));
$drupal_now_timestamp = $this->time->getCurrentTime();
$days_in_month = 30;
$seconds_in_month = 60 * 60 * 24 * $days_in_month;
$seconds_in_year = 60 * 60 * 24 * 365;
$subscription_end_timestamp = '';
// If the user has already purchased, extend the subscription.
if ($customer->hasRole('subscriber')) {
$current_subscription_datetime = $customer->get('field_datetime_sub_end')->value;
$current_subscription_datetime_object = DateTime::createFromFormat(DateTimeItemInterface::DATETIME_STORAGE_FORMAT, $current_subscription_datetime);
$current_subscription_timestamp = $current_subscription_datetime_object->getTimestamp();
if ($total_price === $price_monthly) {
$subscription_end_timestamp = $current_subscription_timestamp + $seconds_in_month;
$customer->set('field_list_text_sub_period', $subscription_monthly);
}
elseif ($total_price === $price_annual) {
$subscription_end_timestamp = $current_subscription_timestamp + $seconds_in_year;
$customer->set('field_list_text_sub_period', $subscription_annual);
}
else {
$this->loggerFactory->error('Total price was outside the assigned range for subscription. (has subscriber role)');
}
}
// Set the date for a new subscription.
else {
// The user is henceforth a subscriber.
$customer->addRole('subscriber');
if ($total_price === $price_monthly) {
$subscription_end_timestamp = $drupal_now_timestamp + $seconds_in_month;
$customer->set('field_list_text_sub_period', $subscription_monthly);
}
elseif ($total_price === $price_annual) {
$subscription_end_timestamp = $drupal_now_timestamp + $seconds_in_year;
$customer->set('field_list_text_sub_period', $subscription_annual);
}
else {
$this->loggerFactory->notice('Total price was outside the assigned range for subscription. (no subscriber role)');
}
}
$subscription_end_date_object = new DateTime();
$subscription_end_date_object->setTimezone(timezone_open('UTC'));
$subscription_end_date_object->setTimestamp("$subscription_end_timestamp");
$subscription_end_date_value = $subscription_end_date_object->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT);
$customer->set('field_datetime_sub_end', $subscription_end_date_value);
$customer->save();
}
}
/**
* Tries to jump to checkout, skipping cart after adding certain items.
*
* @param Drupalcommerce_cartEventCartEntityAddEvent $event
* The add to cart event.
*/
public function tryJumpToCheckout(CartEntityAddEvent $event) {
// $purchased_entity = $event->getEntity();
// if ($purchased_entity->bundle() === 'training_event') {
$checkout_url = Url::fromRoute('commerce_checkout.form', (
'commerce_order' => $event->getCart()->id(),
))->toString();
$this->requestStack->getCurrentRequest()->attributes->set('mymodule_jump_to_checkout_url', $checkout_url);
// Clear status message "Product added to your cart".
// @todo fix deprecated code.
// drupal_get_messages('status');.
}
/**
* Checks if a redirect url has been set.
*
* Redirects to the provided url if there is one.
*
* @param SymfonyComponentHttpKernelEventFilterResponseEvent $event
* The response event.
*/
public function checkRedirectIssued(FilterResponseEvent $event) {
$request = $event->getRequest();
$redirect_url = $request->attributes->get('mymodule_jump_to_checkout_url');
if (isset($redirect_url)) {
$event->setResponse(new RedirectResponse($redirect_url));
}
}
}