Skip to content

Instantly share code, notes, and snippets.

@Kazanir
Last active June 17, 2020 08:27
Show Gist options
  • Save Kazanir/ab613f2f1c5d2269b7605e0dab3ca142 to your computer and use it in GitHub Desktop.
Save Kazanir/ab613f2f1c5d2269b7605e0dab3ca142 to your computer and use it in GitHub Desktop.
Drupal Commerce Recurring 2.x Design Notes & Specification

Commerce Recurring 2.x

Intro

Recurring billing is an extremely complex feature with many use cases. In order to support all of these use cases in a robust way, a fully-featured Recurring module for Drupal Commerce 2.x needs to support them all, and be properly configurable/pluggable in a way that allows sufficient customization for developers while also being usable for store administrators.

This is a specification that attempts to transfer our accumulated knowledge from years of working on Commerce License Billing 1.x while also taking advantage of the designs Bojan and I worked on with Actualys and the Platform.sh team back in January.

The Overall Picture

The recurring module uses a series of linked entities and plugins to drive a recurring billing process. These are:

Recurring Engine Type: A plugin which provides logic to determine how to generate successive recurring orders. Gains configuration from the Recurring Engine configuration entities which provide the plugin configation form ala payment gateways. We also envision that the engine will be used to transport orders to a remote system (such as Paypal or Stripe managed subscription plans) rather than using the default card-on-file charge methods. Engine types (i.e. for Stripe or for Paypal's own recurring systems) will need to provide their own recurring order bundle with the necessary fields if the existing recurring metadata is insufficient for them to operate.

Recurring Engine: A config entity which provides the necessary config for a Recurring Engine Type. Invokes the proper engine plugin methods to create new or repeated Recurring Orders for a given customer. TBD is how each engine will recognize its orders to process their close and renewal; I assume we'll just store an entity reference to the engine on the recurring order itself.

Recurring Order: A new bundle of order which has a completely separate workflow (partially managed by billing cycles) from cart orders. Recurring orders generate order items based on their attached Subscriptions (see below) and are normally paid via card-on-file charges once they reach the appropriate workflow stage.

Recurring Order Item: A new bundle of order item which is used in recurring orders. Each recurring line item is generated by a Charge Object which are in turn generated by a Subscription and its plugin-specific methods for charge collection.

Subscription Type: A plugin which provides the logic that a Subscription needs to function. Primarily this logic is around knowing when to create themselves during a normal (cart or admin) order workflow, typically triggered by a product being checked out which has a trait field containing the plugin config for the Subscription Type. Also, the type plugins provide the logic which refers to the generating entity (product, order, bundle, license, etc.) and generates a set of Charge Objects which are then used to create the recurring order's items. Part of the default plugin config is always a reference to a Recurring Cycle Type which is how a subscription determines to which billing cycle and order pair to attach itself.

Subscription: The actual entity generated by a subscribable entity when it is checked out. The subscription contains both its plugin configuration (for its Subscription Type bundle plugin) as well as references to its generating data or entity. When recurring orders are refreshed, all attached Subscriptions collect their charges (based on the underlying entities or data.) These Charge Objects are stateless representations of the base charges that a Subscription desires for a specific billing cycle.

Usage Group: A plugin which uses type-specific logic to record usage information and generate charges for a subscription. Subscriptions implementing the UsageInterface can register one or more usage groups (ideally via the UI although we haven't contemplated this in 1.x yet) and they will generate appropriate Charge Objects for the recurring order.

Usage Record: A value entity generated when usage is added to a usage group. Various usage groups use the records in different ways, and then from them compute the necessary Charge Objects to add to the Subscription at order refresh time.

Charge Object: A value object which is generated by a subscription based on its plugin/bundle logic. For a product bundle, this means generating a charge for each product in the bundle. For any subscription using usage records, this would be all usage charges generated by each usage group. For subscriptions with varying states (i.e. the ability to suspend a subscription temporarily) this might involve charges split up along those timeframes.

Matching Service: We plan to use a set of tagged services to match Charge Objects (which are stateless containers for a base charge) to existing Order Items with the goal of refreshing an order and not re-saving the order entity or order items if they have not been changed. However, different types of charge objects might request non-default matching schemes, so we allow them to do this via a service plugin (since they are normally just value objects and not linked to any specific logic.)

Order Item Generator Service: Similarly, we need logic that generates the actual order item from a Charge Object if there is no existing order item to be found. We also allow other services to be used here in case an override is desired.

This gives us the following overall workflow:

  1. Various entities are considered "subscribable", and configure a subscription type as part of an entity trait field on the product or order. (One major outstanding architectural question is if there is a unified way of doing this that lets us handle entire-orders, product bundles, individual products, and licenses-from-products gracefully.)

  2. When an order reaches a completed workflow state (exact states TBD), it reviews itself and its order items for the presence of subscribable entities.

  3. Based on the results from #2, a Subscription is generated and (using its own reference to a Recurring Cycle Type) it is assigned to a Recurring Cycle and Recurring Order pair. (This logic is smart enough to generate and save appropriate cycle and order entities if none are present for the desired user and timestamp.

  4. Once attached to an order (presumably via a reverse reference field on the order) the Subscription can then modify the order refresh process. It generates the appropriate charges for the order's Recurring Cycle,

  5. The attached charges are turned into Order Items appropriately and the Recurring Order is saved if any changes have been made since the last refresh.

  6. On cron, billing cycles are evaluated and any whose end has passed have their ->close() and ->renew() operations queued up for processing.

  7. This has the effect of spawning a new billing cycle and order (renewing the cycle) which contains the same set of subscriptions and also charging the order as its workflow state is advanced (closing the cycle) to a payment requested state of some kind.

/**
 * Entity bundle plugin for billing cycle types.
 */
interface RecurringEngine {
  /**
   * @param Account $account
   * @param DateTime $startTime
   * @return RecurringCycle $cycle
   */
  getRecurringCycle(Account $account, DateTime(?) $startTime)

  /**
   * @param RecurringCycle $cycle
   * @return RecurringCycle $newCycle
   */
  getNextRecurringCycle(RecurringCycle $cycle)

  /**
   * @param RecurringCycle $cycle
   * @return ??? $status
   * Renew the cycle. Base implementation mimicks the 1.x version:
   *   - Change the cycle workflow (?) to renewed (and bail if renewal has
   *   already taken place? Unclear.)
   *   - Get all subscriptions from the order attached to the cycle
   *   - Run all scheduled changes on each subscription, if any
   *   - Renew all subscriptions
   *   - For each subscription, get the billing cycle type and next cycle
   *   - For each cycle type + license list, create a new recurring order
   * Non-standard implementations of this are possible and at-your-own-risk.
   */
  renewCycle(RecurringCycle $cycle)

  /**
   * @param RecurringCycle $cycle
   * @return ??? $status
   * Close the cycle. Base implementation mimicks the 1.x version:
   *   - Change the cycle workflow (?) to closed (and bail if it is already
   *   closed? Unclear.)
   *   - Get all the subscriptions from the order.
   *   - For each subscription, check if it can be charged
   *   - Normally this is about usage groups but other implementations are
   *   possible
   *   - Move the order workflow to payment pending (?) if possible
   *   - Do any requested cleanup from ... the subscription/usage groups/billing
   *   cycle type? Gotta think about this.
   *   - Otherwise change order workflow to completion_pending? (Formerly
   *   usage_pending...)
   */
  closeCycle(RecurringCycle $cycle)

  /**
   * @param RecurringOrder $order
   * @return ??? $status
   * Refreshes an order.
   *
   * @TODO: Assuming we stick with this existing on the recurring engine
   * plugin, we'll want our custom order refresher to phone home to this.
   *
   */
  refreshOrder(Order $order)

  /**
   * @param Order $previousOrder
   * @param RecurringCycle $cycle
   * @param Subscription[] $subscriptions
   * @return RecurringOrder $newOrder
   * Generate a recurring order for a set of subscriptions.
   *   - If an order already exists for the billing cycle, it will be used
   *   - Otherwise a new order is generated
   *   - A defined set of values (especially customer profiles) are copied to
   *   the new order
   *   - Add the requested subscriptions to the attachment subscription
   *   reference array on the order
   */
  createRecurringOrder($previousOrder = NULL, )

  /**
   * @TODO: Figure out how the billing engine needs to be involved in order 
   * item creation and pricing, if at all. I feel like for the periodic plugin
   * type to work we should not be hard-coding any of its time-based assumptions
   * but I'm not sure of the code layout that will give us what we want here.
   *
   * Stay tuned.
   */ 
}

/**
 * Defines the recurring cycle type entity class.
 *
 * @ContentEntityType(
 *   id = "commerce_recurring_cycle_type",
 *   label = @Translation("Recurring cycle type"),
 *   label_collection = @Translation("Recurring cycle types"),
 *   label_singular = @Translation("recurring cycle type"),
 *   label_plural = @Translation("recurring cycle types"),
 *   label_count = @PluralTranslation(
 *     singular = "@count recurring cycle type",
 *     plural = "@count recurring cycle types",
 *   ),
 *   bundle_label = @Translation("Recurring engine"),
 *   bundle_plugin_type = "commerce_recurring_engine",
 *   handlers = {
 *     "access" = "Drupal\commerce_recurring\RecurringCycleTypeAccessControlHandler",
 *     "list_builder" = "Drupal\commerce_recurring\RecurringCycleTypeListBuilder",
 *     "storage" = "Drupal\commerce_recurring\RecurringCycleTypeStorage",
 *     "form" = {
 *       "edit" = "Drupal\commerce_recurring\Form\RecurringCycleTypeEditForm",
 *       "delete" = "Drupal\commerce_recurring\Form\RecurringCycleTypeDeleteForm"
 *     },
 *     "route_provider" = {
 *       "default" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider",
 *     },
 *   },
 *   base_table = "commerce_recurring_cycle_type",
 *   admin_permission = "administer commerce_recurring_cycle_type",
 *   fieldable = TRUE,
 *   entity_keys = {
 *     "id" = "recurring_cycle_type_id",
 *     "name" = "machine_name", // ?
 *     "uuid" = "uuid",
 *     "bundle" = "engine"
 *   },
 *   links = {
 *     "collection" = "/admin/commerce/config/recurring-cycle-types",
 *     "canonical" = "/admin/commerce/config/recurring-cycle-types/{recurring_cycle_type}/edit",
 *     "edit-form" = "/admin/commerce/config/recurring-cycle-types/{recurring_cycle_type}/edit",
 *     "delete-form" = "/admin/commerce/config/recurring-cycle-types/{recurring_cycle_type}/delete",
 *   },
 * )
 *
 * TBD if using a content entity as the bundle for another content entity is
 * going to blow up in my face. Stay tuned.
 *   
 */
class RecurringCycleType extends ContentEntityBase implements RecurringCycleTypeInterface {
  // No need for fields, but we need a trait to fuel the config for the engine
  // plugin that serves as the bundle.

}

/**
 * Defines the recurring cycle entity class.
 *
 * @ContentEntityType(
 *   id = "commerce_recurring_cycle",
 *   label = @Translation("Recurring cycle"),
 *   label_collection = @Translation("Recurring cycles"),
 *   label_singular = @Translation("recurring cycle"),
 *   label_plural = @Translation("recurring cycles"),
 *   label_count = @PluralTranslation(
 *     singular = "@count recurring cycle type",
 *     plural = "@count recurring cycle types",
 *   ),
 *   bundle_label = @Translation("Recurring cycle type"),
 *   bundle_entity_type = "commerce_recurring_cycle_type",
 *   handlers = {
 *     "access" = "Drupal\commerce_recurring\RecurringCycleAccessControlHandler",
 *     "list_builder" = "Drupal\commerce_recurring\RecurringCycleListBuilder",
 *     "storage" = "Drupal\commerce_recurring\RecurringCycleStorage",
 *     "route_provider" = {
 *       "default" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider",
 *     },
 *   },
 *   base_table = "commerce_recurring_cycle",
 *   admin_permission = "administer commerce_recurring_cycle",
 *   fieldable = TRUE,
 *   entity_keys = {
 *     "id" = "recurring_cycle_id",
 *     "uuid" = "uuid",
 *     "bundle" = "type"
 *   },
 *   links = {
 *     "collection" = "/admin/commerce/config/recurring-cycles",
 *     "canonical" = "/admin/commerce/config/recurring-cycles/{recurring_cycle}",
 *   },
 * )
 *
 * Recurring cycles don't have a UI -- they are created by various
 * recurring/subscription processes and then deleted if their order is deleted
 * by some other method. @TODO is we could provide a workflow-only UI to allow
 * people to manually close/renew cycles early or late but it is not clear if
 * this can be made to work with even the default periodic engine
 * implementation. 
 *
 */
class RecurringCycle extends ContentEntityBase implements RecurringCycleInterface {
  public function baseFieldDefinitions() {
    $fields = parent::baseFieldDefinitions($entity_type);

    $fields['uid'] = BaseFieldDefinition::create('entity_reference')
      ->setLabel(t('Owner'))
      ->setDescription(t('The user ID of the license owner.'))
      ->setSetting('target_type', 'user')
      ->setSetting('handler', 'default')
      ->setDefaultValueCallback('Drupal\commerce_recurring\Entity\RecurringCycle::getCurrentUserId')
      ->setDisplayOptions('view', array(
        'label' => 'hidden',
        'type' => 'author',
        'weight' => 0,
      ))
      ->setDisplayConfigurable('form', TRUE)
      ->setDisplayConfigurable('view', TRUE);

    $fields['state'] = BaseFieldDefinition::create('state')
      ->setLabel(t('State'))
      ->setDescription(t('The recurring cycle state.'))
      ->setRequired(TRUE)
      ->setSetting('max_length', 255)
      ->setDisplayOptions('view', [
        'label' => 'hidden',
        'type' => 'state_transition_form',
        'weight' => 10,
      ])
      ->setDisplayConfigurable('form', TRUE)
      ->setDisplayConfigurable('view', TRUE)
      ->setSetting('workflow_callback', ['\Drupal\commerce_recurring\Entity\RecurringCycle', 'getWorkflowId']);
    
    // @TODO Unclear how to generalize this if we rely on the RecurringEngine
    // plugin model.
    $fields['start'] = BaseFieldDefinition::create('timestamp')
      ->setLabel(t('Start'))
      ->setDescription(t('The start date of the recurring cycle.'))
      ->setDisplayOptions('view', [
        'label' => 'hidden',
        'type' => 'timestamp',
        'weight' => 1,
        'settings' => [
          'date_format' => 'custom',
          'custom_date_format' => 'n/Y',
        ],
      ])
      ->setDisplayConfigurable('view', TRUE)
      ->setDefaultValue(0);
   
    // @TODO Unclear how to generalize this if we rely on the RecurringEngine
    // plugin model.
    $fields['end'] = BaseFieldDefinition::create('timestamp')
      ->setLabel(t('End'))
      ->setDescription(t('The end date of the recurring cycle.'))
      ->setDisplayOptions('view', [
        'label' => 'hidden',
        'type' => 'timestamp',
        'weight' => 1,
        'settings' => [
          'date_format' => 'custom',
          'custom_date_format' => 'n/Y',
        ],
      ])
      ->setDisplayConfigurable('view', TRUE)
      ->setDefaultValue(0);

  }

  /**
   * Default value callback for 'uid' base field definition.
   *
   * @see ::baseFieldDefinitions()
   *
   * @return array
   *   An array of default values.
   */
  public static function getCurrentUserId() {
    return [\Drupal::currentUser()->id()];
  }
}

// @TODO: Usage groups

// Recurring order type. config/install/commerce_order.commerce_order_type.recurring.yml
// @TODO: Where do we attach a different OrderRefresh object to this?
// @TODO: Attach subscription references field to this

langcode: en
status: true
label: Recurring
id: recurring
workflow: order_recurring
traits: {  }
refresh_mode: recurring
refresh_frequency: 300
sendReceipt: true
receiptBcc: ''

// config/install/field.field.commerce_order.recurring.order_subscriptions.yml

langcode: en
status: true
dependencies:
  config:
    - commerce_order.commerce_order_type.recurring
    - field.storage.commerce_order.order_subscriptions
id: commerce_order.recurring.order_subscriptions
field_name: order_subscriptions
entity_type: commerce_order
bundle: recurring
label: 'Subscriptions'
description: ''
required: true
translatable: false
default_value: {  }
default_value_callback: ''
settings:
  handler: 'default:commerce_subscription'
  handler_settings: {  }
field_type: entity_reference


// config/install/field.storage.commerce_order.order_subscriptions.yml

langcode: en
status: true
dependencies:
  module:
    - commerce_order
id: commerce_order.order_subscriptions
field_name: order_subscriptions
entity_type: commerce_subscription
type: entity_reference
settings:
  target_type: commerce_subscription
module: core
locked: true
cardinality: -1
translatable: false
indexes: {  }
persist_with_no_fields: false
custom_storage: false

// Recurring order item type. config/install/commerce_order.commerce_order_item_type.recurring.yml

langcode: en
status: true
dependencies:
  enforced:
    module:
      - commerce_recurring
label: 'Recurring'
id: recurring
purchasableEntityType: commerce_subscription
orderType: recurring
// @TODO: We might want a plugin field for usage groups here?
traits: {  }
// @TODO: We might also want a way to record the charge object
// responsible for this order item. It has come up before...


/**
 * Defines the subscription entity.
 *
 * @ingroup commerce_recurring
 *
 * @ContentEntityType(
 *   id = "commerce_subscription",
 *   label = @Translation("Subscription"),
 *   label_collection = @Translation("Subscriptions"),
 *   label_singular = @Translation("subscription"),
 *   label_plural = @Translation("subscriptions"),
 *   label_count = @PluralTranslation(
 *     singular = "@count subscription",
 *     plural = "@count subscription",
 *   ),
 *   bundle_label = @Translation("Subscription type"),
 *   bundle_plugin_type = "commerce_subscription_type",
 *   handlers = {
 *     "access" = "Drupal\commerce_recurring\SubscriptionAccessControlHandler",
 *     "list_builder" = "Drupal\commerce_recurring\SubscriptionListBuilder",
 *     "storage" = "Drupal\commerce_recurring\SubscriptionStorage",
 *     "form" = {
 *       "default" = "Drupal\commerce_recurring\Form\SubscriptionForm",
 *       "checkout" = "Drupal\commerce_recurring\Form\SubscriptionCheckoutForm",
 *       "edit" = "Drupal\commerce_recurring\Form\SubscriptionForm",
 *       "delete" = "Drupal\commerce_recurring\Form\SubscriptionDeleteForm",
 *     },
 *     "views_data" = "Drupal\views\EntityViewsData",
 *     "route_provider" = {
 *       "default" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider",
 *     },
 *   },
 *   base_table = "commerce_subscription,
 *   admin_permission = "administer subscriptions",
 *   fieldable = TRUE,
 *   entity_keys = {
 *     "id" = "subscription_id",
 *     "bundle" = "type",
 *     "uuid" = "uuid",
 *     "uid" = "uid",
 *   },
 *   links = {
 *     "canonical" = "/admin/commerce/subscriptions/{commerce_subscription}",
 *     "edit-form" = "/admin/commerce/subscriptions/{commerce_subscription}/edit",
 *     "delete-form" = "/admin/commerce/subscriptions/{commerce_subscription}/delete",
 *     "collection" = "/admin/commerce/subscriptions",
 *   },
 * )
 */
 *
 */
class Subscription extends ContentEntity {

}

/**
 * Defines the interface for subscription types.
 */
interface SubscriptionTypeInterface extends BundlePluginInterface, ConfigurablePluginInterface, PluginFormInterface {

  /**
   * Gets the subscription type label.
   *
   * @return string
   *   The subscription type label.
   */
  public function getLabel();

  /**
   * Build a label for the given subscription type.
   *
   * @param \Drupal\commerce_recurring\Entity\SubscriptionInterface $license
   *
   * @return string
   *   The label.
   */
  public function buildLabel(SubscriptionInterface $subscription);

  /**
   * Gets the workflow ID this this subscription type should use.
   *
   * @return string
   *   The ID of the workflow used for this subscription type.
   */
  public function getWorkflowId();

  /**
   * Generate the charges for this subscription and a given recurring cycle.
   */
  public function collectCharges(RecurringCycle $cycle) {
    // Default implementation here varies a lot:
    // Does the subscription represent:
    //   a. A single product or bundle?
    //   b. An entire cart / repeated order of some kind?
    //   c. A license which has recurring billing configured?
    //   d. Any one of these things, plus usage groups?

    // The answer to this question is one of the key parts of the
    // subscription type plugin and determines the implementation.
  }

  /**
   * Check whether plan changes can be made to this subscription during
   * the middle of a recurring cycle.
   */
  public function enforceChangeScheduling(RecurringCycle $cycle) {

  }
}

/**
 * Usage group plugin type.
 */
interface RecurringUsageGroup {
  /**
   * Determines whether this usage group plugin should block the subscription's plan from being changed midstream.
   */
  public function enforceChangeScheduling(RecurringCycle $cycle) {

  }

  /**
   * Returns a list of usage records for a given recurring cycle.
   */
  public function usageHistory(RecurringCycle $cycle);

  /**
   * Adds usage for this usage group and subscription and
   * recurring cycle.
   */
  public function addUsage(mixed $usage, RecurringCycle $cycle);

  /**
   * Gets the current usage (normally an integer, but who knows)
   * for this usage group.
   */
  public function currentUsage();

  /**
   * Checks whether usage records are complete for a given recurring
   * cycle or whether the subscription needs to "wait" on remote
   * services that might record usage data into the system later.
   */
  public function isComplete();

  /**
   * Returns the charges for this group and a given recurring cycle.
   */
  public function getCharges(RecurringCycle $cycle);

  /**
   * We need something to react to changes in the subscription plan.
   * In 1.x this was "onRevisionChange" but that might not make sense anymore.
   */
  public function onPlanChange();
}

// @TODO: Charge value object class definition.
// @TODO: Service to transform charge objects into order items.
@heddn
Copy link

heddn commented Sep 4, 2017

Billing Engine: will this allow us to track the actual invoicing of stripe/paypal payments
directly from Drupal? Meaning, will an order get kicked off so I only have to export
from Drupal for my quickbooks accounting? And not also from Stripe or paypal.

Recurring Order: making it a bundle kinda makes sense, but what if I just want to add recurring to the default order bundle? Would that be possible too?

Recurring Order Item: it isn't clear the need for a special order type. Although I trust that it makes sense, I think it would help to clarify why.

workflow
#4: why does it have to be a field on the order, why not on the subscription?

I don't see any mention of Dunning. Was that taken into acount when desiging this architecture?

@Kazanir
Copy link
Author

Kazanir commented Sep 6, 2017

  • In 1.x the billing engine was just responsible for generating a new cycle from a timestamp+account or previous cycle based on the settings on the billing cycle type. That model remains in place here, but I want to extend the billing engine plugin (as you can see above) to implement the order-generation and order-refresh and cycle-close/renew logic that is currently hard-coded. I haven't quite gotten this far but the idea of the billing engine defining the order workflow also makes sense to me, but might be technically tricky to arrange. The ultimate goal here is to make the engine pluggable enough that we can have Drupal send the entire order to Paypal or Stripe as a recurring subscription and let them handle the rest -- rather than using the base card on file payment implementation. The payment transactions for card on file are already stored normally -- exports from Stripe/Paypal are a different question.

  • The concept of "adding recurring" to the default order bundle doesn't work. The normal order bundle has a completely different workflow and needs new fields and cannot be used for recurring. If what you mean is, "allow people who check out a shopping cart to mark that order as 'recurring' and repeat it every X days," then that use case will be supported by one of the default 3-4 subscription type plugins.

  • The same logic applies to the order item type -- in 1.x it needed far different fields than the normal order item type (in particular an always-filled reference to the subscription and other billing-engine-specific fields like start and end times are anticipated here.) A new bundle is the right move.

  • The workflow of a recurring order is what drives it being paid for, checked for completion & usage, moves it into the Dunning process, etc. (This answers your next question.) The subscription also has a workflow to denote its state(i.e. Active, Suspended, Cancelled) but this is distinct from the order, which pertains to a specific billing cycle and has a workflow related to payment and fulfillment like other orders. Two different workflows which represent different things on different entities.

@joachim-n
Copy link

Recurring Engine: A plugin which provides logic to determine how to generate successive billing cycles.

Something for this is already in Commerce License, as the expiration type plugin: http://cgit.drupalcode.org/commerce_license/tree/src/Plugin/Commerce/LicenseExpiration?h=8.x-2.x

There is an issue to move it out of that module and into its own project, so that both Commerce License and Commerce Recurring can use it without becoming dependent on one another: https://www.drupal.org/node/2906553

Recurring Order: A new bundle of order
Recurring Order Item: A new bundle of order item which is used in recurring orders

These should not be a new order types or new order item type, but traits that can be applied to any order type and order item type. Otherwise, this won't be compatible with Commerce License, which also needs a specialized order item type and order type.

Recurring Cycle Type: A config entity which provides the necessary config for a Recurring Engine (which acts as its bundle plugin).

That's not doable -- config entities don't have bundles.

I think what you mean here is that the Recurring Cycle content entity's bundles are defined by the Recurring Engine plugins. With that sort of setup, there is no bundle config entity in between. But with that, there's no way of configuring the plugin -- there is only one instance of each plugin as a bundle. See below though...

Recurring Cycle: A "content" entity which governs the recurring workflow for an order as well as recording the time period to which it applies.

I don't quite follow by we need the Recurring Cycle content entity to be separate from the Subscription content entity. It would be possible to store the configured Recurring Engine plugin on the Subscription content entity.

Presumably it's for this part:

it is assigned to a Recurring Cycle and Recurring Order pair. (This logic is smart enough to generate and save appropriate cycle and order entities if none are present for the desired user and timestamp.

but I don't see the use case for attaching a Subscription to an existing Recurring Cycle and order -- why not just have multiple orders that will recur?

At any rate, I think the Recurring Engine / Recurring Cycle Type / Recurring Cycle part needs some further thought and planning, as I'm not sure that the architecture described here is actually suited to what it's trying to do.

I think your proposed interface for Recurring Engine is far too heavy. The expiration type plugins we wrote for Commerce License just concern themselves with configuring a time period, and producing a timestamp for the end of the time period.

We also envision that the engine will be used to transport orders to a remote system (such as Paypal or Stripe managed subscription plans) rather than using the default card-on-file charge methods.

That should be a different plugin type, which only concerns itself with how to drive orders.

One other use case that the Recurring Engine system needs to deal with is the need for both admin-configured and customer-configured renewal period:

  • for a recurring grocery shop, it's entirely up to the customer when their order renews. They select whether it's weekly, monthly, etc.
  • for a License, it's configured by the store admin. They sell a license that is for 1 year rolling, or fixed for 1 year from Jan 1, for example.

That means that in some instances, the Recurring Engine / expiration plugin is something that's configured on the Product Variation, and then is used as a template for creating something when the product is purchased, and in others, it's configured by the user.

In either of those scenarios, we don't need a Recurring Cycle Type: either the plugin configuration is stored on something else initially, or the customer creates the configured plugin when they purchase the product.

If we consider a Recurring Engine / expiration plugin that's, say, 'fixed period', we don't want each customer who creates a fixed period subscription to cause a new config entity to be created!

@joachim-n
Copy link

joachim-n commented Sep 11, 2017

These should not be a new order types or new order item type, but traits that can be applied to any order type and order item type. Otherwise, this won't be compatible with Commerce License, which also needs a specialized order item type and order type.

On second thoughts, I may have misunderstood this bit.

When a repeat order is created, does it follow the usual flow of creating order items based on the product variation that is to be purchased?

If so, then there's an issue with preventing any behaviours that would normally happen when that product is purchased.

More specifically, in Commerce License, we react when an order reaches fulfilment, and check whether the product that an order item is for has the field for a license type (see LicenseOrderSyncSubscriber::getOrderItemsWithLicensedProducts()). If so, we create the License entity, and it then follows its own flow of being granted, etc.

Now for a repeat order, we don't want that to happen, since there is already a License entity.

Now if repeat orders use their own item type, Commerce License could detect a license-compatible order item type rather than a product variation (Commerce License provides an entity trait for Order Item types). But then that brings us to the problem that Product Variation types dictate the Order Item type that is used when they are added to a cart. That logic is done by OrderItemStorage::createFromPurchasableEntity(), and looks baked pretty deep in.

I don't see how to resolve this at the moment -- though it's possible I've misunderstood something!

@joachim-n
Copy link

That logic is done by OrderItemStorage::createFromPurchasableEntity(), and looks baked pretty deep in.

Ah, ok, I get it now -- sorry for the noise!

OrderItemStorage::createFromPurchasableEntity() is used by the add to cart form to determine the type of Order Item to create. However, a repeat order can create a different type of Order Item for its products.

That does mean though that any code in other modules that does anything with purchased products in an event subscriber must react to order item type, and not just the product.

@wizonesolutions
Copy link

Hmm, won't making Recurring Cycle Types content entities prevent them from being exported into configuration? I can imagine that this kind of configuration would be relevant across various environments. At least, I export my billing cycle types in D7.

@Kazanir
Copy link
Author

Kazanir commented Sep 18, 2017

but I don't see the use case for attaching a Subscription to an existing Recurring Cycle and order -- why not just have multiple orders that will recur?

The use case here is a synchronous cycle type, in which some subset of subscriptions a user could buy are all attached to the same recurring cycle, so that they are all billed simultaneously.

At any rate, I think the Recurring Engine / Recurring Cycle Type / Recurring Cycle part needs some further thought and planning, as I'm not sure that the architecture described here is actually suited to what it's trying to do.

In 1.x you had 3 layers:

  1. Engine supplied the actual logic to figure out billing cycles
  2. Cycle type was exportable/config that supplied the fields for the engine logic to run
  3. Cycle itself was generated by the cycle type (per user + period) and stored the start/end dates and workflow

In the code above I have envisioned roughly the same workflow, although now cycle types have to become a content entity due to the bundle restrictions. If we can come up with something better that meets these needs -- then we can change the arch accordingly.

Hmm, won't making Recurring Cycle Types content entities prevent them from being exported into configuration? I can imagine that this kind of configuration would be relevant across various environments. At least, I export my billing cycle types in D7.

Yes, like with Stores, this is an issue I don't think we have solved yet. (Or maybe we have, I saw something new about Stores for 2.x recently but haven't kept up.)

I need to think about Joachim's point about configurability more and see if it maps to the existing model somehow or if we need to expand our minds.

@joachim-n
Copy link

I've split off the expiration plugin from CommLic into its own project: https://www.drupal.org/project/recurring_period

This is so it can be used by CommRec too.

If the two modules used different ways of configuring time periods, we'd end up with having to translate between the two systems, which would be a pain.

@Kazanir
Copy link
Author

Kazanir commented Sep 25, 2017

I have given the Engine/CycleType/Cycle thing some thought. The problem is that bundle plugins originally had a very specific use case -- you wanted to use a plugin as a bundle because the plugin would provide specific fields to the bundle, which fields it required in order to operate. Importantly, a given plugin (say, a RecurringEngine or a LicenseType) would require a different set of fields on a per-plugin basis, because it would use those fields to implement the internal logic of its interface functions. In the license case, this meant a given license type might store the role it granted on the license, while another would store the file ID. In the recurring engine case, we only provided one type (periodic) but this had specific fields (pce_async and pce_period) which it required.

This logic still holds for licenses and I think it holds for the Engine/CycleType combo I have described above. Hence the "weird" usage of a ContentEntity as a bundle entity for the recurring cycle itself. The other option is that we would change the CycleType to a more traditional ConfigEntity, optionally using a deriver to spawn an instance of the plugin for each CycleType -- but that leaves us unable to answer the plugin-specific config fields question.

@Kazanir
Copy link
Author

Kazanir commented Sep 28, 2017

After talking to Bojan we are tentatively ditching the idea of a billing cycle, and will turn the cycle type + engine plugin combination into a config entity (Recurring Engine) and plugin type (Recurring Engine Type? This is to-be named). We'll replace the recurring cycle entity with a metadata field on the order that defines the start and end date and tracks whether the order has been closed and renewed. (These actions should only happen once and cannot really be tracked by a Workflow since they both need to happen and don't always leave the order in a consistent state...I think.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment