1

I'm new to the Commerce framework. My goal is to sell licenses to users (via the commerce_license module). Purchasing a license should grant the user a particular role for the duration of the license.

I need to offer the license in four different versions, described below. I'm struggling to figure out how to accomplish this:

  • License is good for one year; does not auto-renew ($99/year)
  • License is good for one year; auto-renews each year (via the commerce_recurring module) ($89/year)
  • License is good for one month; does not auto-renew ($9.99/month)
  • License is good for one month; auto-renews each month ($8.99/month)

Can someone please explain to me how to implement this, in straightforward, step-by-step terms? What product types, product variation types, products, product attributes, order types, order item types, etc. do I need to create – and how should they be configured?

(My main problem is that I really don't understand the relationships between these various objects. I've read the docs. I've watched tutorial videos. I've scrutinized ERDs. I've spent hours experimenting in the Drupal dashboard. I've tried to read the code – but it's too convoluted for me. Maybe I'm dumb, but the Commerce framework is extremely confusing and counterintuitive.)

I would prefer to avoid any custom code – I just need the simplest solution that will satisfy the above requirements.

Any guidance ?

2
  • If you're going to downvote me, please explain how I could have posed this question better. – Travis Miller Jan 20 at 16:47
  • 1
    Have you read the README.txt from the module? There is a step by step walktrough "To create products that grant licenses that renew with a subscription:" How to configure them, depends on your needs. Note that the module is still incomplete, and is known for some limitations. That is probably why the maintainers only released an alpha release. – Joehoe Jan 20 at 17:18
1

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.

  1. Add a role, Premium user. Give this role the additional permissions that users will purchase.
  2. Add a datetime field to the user account, Subscription expire date. Add another list text field to the user account, Subscription type.
  3. Add custom code to restrict permission for the Subscription expire date field to admins only.
  4. Configure your store + checkout flow in Commerce.
  5. Create a product type "My license." Add product variations "One year autorenew," "One month," etc.
  6. 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: Drupal\MYMODULE\EventSubscriber\MyModuleEventSubscriber
    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 Drupal\MYMODULE\EventSubscriber;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Logger\LoggerChannelFactory;
use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface;
use Drupal\state_machine\Event\WorkflowTransitionEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Drupal\commerce_cart\Event\CartEntityAddEvent;
use Drupal\commerce_cart\Event\CartEvents;
use Drupal\Core\Url;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;

/*
 * Event Subscriber MyModuleEventSubscriber.
 */
class MyModuleEventSubscriber implements EventSubscriberInterface {

  /**
   * The request stack.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected $requestStack;

  /**
   * Logger factory.
   *
   * @var \Drupal\Core\Logger\LoggerChannelFactory
   */
  protected $loggerFactory;

  /**
   * The time service.
   *
   * @var \Drupal\Component\Datetime\TimeInterface
   */
  protected $time;

  /**
   * MyModuleEventSubscriber constructor.
   *
   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
   *   The request stack.
   * @param \Drupal\Core\Logger\LoggerChannelFactory $logger_factory
   *   The logger factory.
   * @param \Drupal\Component\Datetime\TimeInterface $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 \Drupal\commerce_cart\Event\CartEntityAddEvent $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 \Symfony\Component\HttpKernel\Event\FilterResponseEvent $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));
    }
  }

}

Not the answer you're looking for? Browse other questions tagged or ask your own question.