I'll take you to the Drupal Commerce 2.x candy shop

Hello, nice to meet you

Today's topics

  • Structure of Drupal Commerce 2.x
  • Plugins provided by Commerce
  • Order processors and price adjustments
  • Price resolvers
  • Useful event subscribers
  • Custom workflows and transitions

Commerce demo module

All the examples used in the presentation can be downloaded here:

commerce_demo.tar.gz

Commerce 2.x

Installation

Recommended to install with Composer.

  • commerceguys/intl (internationalization library)
  • commerceguys/addressing
  • commerceguys/tax (not required)
  • commerceguys/zone (not required)

Dependent contrib

  • Address
  • Entity API
  • Inline Entity Form
  • Entity Reference Revisions

Note: Requires Drupal core >=8.6.0

PHP extensions

  • php7.1-bcmath
  • php7.1-soap (optional)

Drupal VM config.yml:

                    
php_packages_extra:
  - php7.1-bcmath
  - php7.1-soap
                    
                

Module Structure

Commerce is separated into "submodules"

  • Cart
  • Checkout
  • Log
  • Order
  • Payment
  • Price
  • Product
  • Promotion
  • Store
  • Tax

Dependencies

Commerce order:

                    
dependencies:
  - commerce:commerce
  - commerce:commerce_price
  - commerce:commerce_store
                    
                

Structure & Relationships

Chart

Plugins

Plugins

Plugins?

  • Small pieces of functionality that are swappable
  • Implement different behaviors via a common interface
  • Auto discover of every implementation

Commerce Condition

Conditions

Implementation

Add new class with namespace
Drupal\[YOUR MODULE]\Plugin\Commerce\Condition

With annotation
                    
/**
 * Provides the quantity condition for order items.
 *
 * @CommerceCondition(
 *   id = "order_item_quantity",
 *   label = @Translation("Quantity"),
 *   display_label = @Translation("Limit by quantity"),
 *   category = @Translation("Product"),
 *   entity_type = "commerce_order_item",
 * )
 */
                    
                

Implementation

Implement method "evaluate()"
                    
  /*
   * {@inheritdoc}
   */
  public function evaluate(EntityInterface $entity) {
    $this->assertEntity($entity);
    /** @var \Drupal\commerce_order\Entity\OrderItemInterface $order_item */
    $order_item = $entity;
    $quantity = $order_item->getQuantity();

    switch ($this->configuration['operator']) {
      case '==':
        return $quantity == $this->configuration['quantity'];
    }
                    
                

Promotion offer

Promotion offer

Implementation

Add new class with namespace
Drupal\[YOUR MODULE]\Plugin\Commerce\PromotionOffer

With annotation
                    
/**
 * Provides the percentage off offer for order items.
 *
 * @CommercePromotionOffer(
 *   id = "order_item_percentage_off",
 *   label = @Translation("Percentage off each matching product"),
 *   entity_type = "commerce_order_item",
 * )
 */
                    
                

Implementation

                        
class OrderItemPercentageOff extends OrderItemPromotionOfferBase {

  /**
   * {@inheritdoc}
   */
  public function apply(EntityInterface $entity, PromotionInterface $promotion) {
    $this->assertEntity($entity);
    /** @var \Drupal\commerce_order\Entity\OrderItemInterface $order_item */
    $order_item = $entity;
    $adjustment_amount = $order_item->getUnitPrice()->multiply($this->getPercentage());
    $adjustment_amount = $this->rounder->round($adjustment_amount);

    $order_item->addAdjustment(new Adjustment([
      'type' => 'promotion',
      // @todo Change to label from UI when added in #2770731.
      'label' => t('Discount'),
      'amount' => $adjustment_amount->multiply('-1'),
      'percentage' => $this->getPercentage(),
      'source_id' => $promotion->id(),
    ]));
  }

}
                        
                    

Commerce Checkout Flow

Checkout flow

Implementation

Add new class with namespace
Drupal\[YOUR MODULE]\Plugin\Commerce\CheckoutFlow

With annotation
                    
/**
 * Provides the default multistep checkout flow.
 *
 * @CommerceCheckoutFlow(
 *   id = "multistep_default",
 *   label = "Multistep - Default",
 * )
 */
                    
                

Implementation

                    
  class MyFlow extends MultistepDefault {
      /**
       * {@inheritdoc}
       */
      public function getSteps() {
        $steps = parent::getSteps();
        $steps['my_custom_step'] = [
            'label' => $this->t('Label'),
            'previous_label' => $this->t('Go back'),
            'next_label' => $this->t('Continue'),
            'has_sidebar' => TRUE,
        ];

        return $steps;
      }
  }
                    
                

Commerce Checkout Pane

Checkout pane

Implementation

Add new class with namespace
Drupal\[YOUR MODULE]\Plugin\Commerce\CheckoutPane

With annotation
                    
/**
 * Provides an extra checkout pane.
 *
 * @CommerceCheckoutPane(
 *   id = "my_very_cool_pane",
 *   label = @Translation("Pane title"),
 *   default_step = "review", // If you want the default step to be the sidebar, use "_sidebar"
 *   wrapper_element = "container", // or "fieldset"
 * )
 */
                    
                

Add config

                        
class MyVeryCoolPane extends CheckoutPaneBase {
  /**
   * {@inheritdoc}
   */
  public function buildConfigurationSummary() {
    return '';
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {}

  /**
   * {@inheritdoc}
   */
  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {}
}
                        
                    

Build pane form

                        
class MyVeryCoolPane extends CheckoutPaneBase {

  /**
   * {@inheritdoc}
   */
  public function isVisible() {
    if ($this->someCondition()) {
      return TRUE;
    }

    return FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) {
    $pane_form['element']['#markup'] = $this->t('My simple pane');

    return $pane_form;
  }
}
                        
                    

Commerce Tax type

Tax type

Implementation

Add new class with namespance
Drupal\[YOUR MODULE]\Plugin\Commerce\TaxType

With annotation
                    
/**
 * @CommerceTaxType(
 *   id = "my_custom_vat",
 *   label = "My custom VAT",
 * )
 */
                    
                

Implementation

                        
class BelgianTaxType extends TaxTypeBase {

  public function apply(OrderInterface $order) {
    foreach ($order->getItems() as $order_item) {
      // Add fix 21% VAT rate for all order items.
      $order_item->addAdjustment(new Adjustment([
        'type' => 'tax',
        'label' => 'VAT (21%)',
        'amount' => $order_item->getTotalPrice()->multiply('0.21'),
        'percentage' => '0.21',
        'source_id' => $this->entityId . '|be|0.21',
        'included' => $this->isDisplayInclusive(),
      ]));
    }
  }
}
                        
                    

Payment Gateway

Payment gateway

Implementation

Add new class with namespace
Drupal\[YOUR MODULE]\Plugin\Commerce\PaymentGateway

With annotation
                        
/**
 * Provides the cheque bancair payment gateway.
 *
 * @CommercePaymentGateway(
 *   id = "cheque_bancair",
 *   label = "Cheque Bancair",
 *   display_label = "Cheque Bancair",
 *   modes = {
 *     "n/a" = @Translation("N/A"),
 *   },
 *   forms = {
 *     "add-payment" = "Drupal\[YOUR MODULE]\PluginForm\ManualPaymentAddForm",
 *     "receive-payment" = "Drupal\[YOUR MODULE]\PluginForm\PaymentReceiveForm",
 *   },
 *   payment_type = "payment_manual",
 * )
 */
                        
                    

Offsite payment provider

Extend OffsitePaymentGatewayBase

                    
  /**
   * {@inheritdoc}
   */
  public function onNotify(Request $request) {}

  /**
   * {@inheritdoc}
   */
  public function onReturn(OrderInterface $order, Request $request) {}

  /**
   * {@inheritdoc}
   */
  public function onCancel(OrderInterface $order, Request $request) {}
                    
                

Order processors

Order preprocessor

Order processors?

  • Special service defined by Commerce
  • Allows you to manipulation prices
  • Achieved through price adjustments

Collecting the processors

                        
/**
 * Adds order processors to the PriceCalculator, grouped by adjustment type.
 */
class PriceCalculatorPass implements CompilerPassInterface {

  /**
   * {@inheritdoc}
   */
  public function process(ContainerBuilder $container) {
    ...
    foreach ($container->findTaggedServiceIds('commerce_order.order_processor') as $id => $attributes) {
      ...
      $attribute = $attributes[0];
      if (empty($attribute['adjustment_type'])) {
        continue;
      }

      $processors[$id] = [
        'priority' => isset($attribute['priority']) ? $attribute['priority'] : 0,
        'adjustment_type' => $attribute['adjustment_type'],
      ];
    }
}
                        
                    

Defining adjustment type

Naming convention:

[YOUR_MODULE].commerce_adjustment_types.yml

Contents:

                    
custom_adjustment:
  label: 'Custom adjustment'
  singular_label: 'Custom price adjustment'
  plural_label: 'Custom price adjustments'
  has_ui: true
  weight: 200

                    
                

Price adjustment UI

Price adjustment

Defining your service

Drupal\[YOUR MODULE]\[YOUR MODULE].services.yml
                        
services:
  your_module.order_processor:
    class: '\Drupal\[YOUR MODULE]\CommerceOrderProcessor'
    arguments: ['@entity_type.manager']
    tags:
      - { name: 'commerce_order.order_processor', priority: 10, adjustment_type: 'custom_adjustment' }
                        
                    

Implementing processor

                        
class CommerceOrderProcessor implements OrderProcessorInterface {

  protected $manager;

  /**
   * CommerceOrderProcessor constructor.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $calculator
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
    $this->manager = $entity_type_manager;
  }

  /**
   * {@inheritdoc}
   */
  public function process(OrderInterface $order) {
    if($this->someComplicatedCondition()){
       // Apply adjustment.
       $order->addAdjustment(new Adjustment([
          'type' => 'custom_adjustment',
          'label' => 'Custom adjustment',
          'amount' => new Price(2.00, 'EUR'),
       ]));
    }
  }
}
                        
                    

Checkout / Cart

Checkout

Price resolvers

Price resolver

Allow you to

Offer different prices for the same product based on conditions. For example:

  • Price per quantity
  • VIP users (role) have a permanent 10% discount
  • Every first Monday of the month is 80% off day
  • ...

Note: some of the conditions can be achieved through the Commerce Promotion module

What about order processors?

  • Price resolvers are also called by regular price formatters
  • They are accompanied by a Context object
  • The point of the context is to provide known information when the order item is missing

Defining your service

Drupal\[YOUR MODULE]\[YOUR MODULE].services.yml
                        
services:
  your_module.price_resolver:
    class: '\Drupal\[YOUR MODULE]\CustomPriceResolver'
    tags:
      - { name: 'commerce_price.price_resolver', priority: 100}
                        
                    

Implement price resolver

                        
class CustomPriceResolver implements PriceResolverInterface {

  /**
   * {@inheritdoc}
   */
  public function resolve(PurchasableEntityInterface $entity, $quantity, Context $context) {
    if(1 <= $quantity && $quantity < 50){ // Quantity between 1 & 49
      // Use default price.
      return $entity->getPrice();
    }elseif(50 <= $quantity && $quantity < 250){ // Quantity between 50 & 249
      // Give a 10% discount.
      return $entity->getPrice()->multiply('0.90');
    }elseif($quantity >= 250){ // Quantity is 250 or higher
      // Give a 15% discount.
      return $entity->getPrice()->multiply('0.85');
    }

    // Something funky is going on. Use default price.
    return $entity->getPrice();
  }
}
                        
                    

Example in order processor

In this case, the same could be achieved with an order processor:

                        
class OrderProcessor implements OrderProcessorInterface {

  /**
   * {@inheritdoc}
   */
  public function process(OrderInterface $order) {
    $order_items = $order->getItems();
    foreach ($order_items as $order_item) {
        // Insert quantity logic here.
        ...
        // If set to TRUE, price resolvers won't be executed anymore.
        // This way you can make sure this price will be used as order item price.
        $order_item->setUnitPrice($price, FALSE);
    }
  }
}
                        
                    

Another example

                        
class CustomPriceResolver implements PriceResolverInterface {

  /**
   * {@inheritdoc}
   */
  public function resolve(PurchasableEntityInterface $entity, $quantity, Context $context) {
    if($this->account->hasRole('vip'){
      // Give 5% discount.
      return $entity->getPrice()->multiply('0.95');
    }

    // Use default price
    return $entity->getPrice();
  }
}
                        
                    

Note: This can be achieved with the Promotion module

Event subscribers

Event subscriber

Event subscribers?

  • Allow different components to interact and communicate with each other
  • One component dispatches the event = DISPATCHER
  • Other components can register as event subscribers = LISTENER

Partially replaced hooks

Useful Commerce events

const CART_EMPTY = 'commerce_cart.cart.empty';
const CART_ENTITY_ADD/UPDATE/REMOVE = 'commerce_cart.entity.add/update/remove';
const ORDER_ITEM_COMPARISON_FIELDS = 'commerce_cart.order_item.comparison_fields';
const ORDER_ASSIGN = 'commerce_order.order.assign';
const PRODUCT_VARIATION_AJAX_CHANGE = 'commerce_product.commerce_product_variation.ajax_change';
const FILTER_VARIATIONS = 'commerce_product.filter_variations';
const CUSTOMER_PROFILE = 'commerce_tax.customer_profile';
                        
// The format for adding a state machine event to subscribe to is:
// {group}.{transition key}.pre_transition or {group}.{transition key}.post_transition
// depending on when you want to react.
$events = ['commerce_order.place.pre_transition' => 'onPreOrderPlace'];
$events = ['commerce_order.place.post_transition' => 'onOrderPlace'];
                        
                    

Defining your service

Drupal\[YOUR MODULE]\[YOUR MODULE].services.yml
                        
services:
  your_module.event_subscriber:
    class: '\Drupal\[YOUR MODULE]\EventSubscriber\CommerceCartSubscriber'
    tags:
      - { name: 'event_subscriber'}
                        
                    

Implementing your event subscriber

                        
class CommerceCartSubscriber implements EventSubscriberInterface {

  /**
   * Callback for ORDER_ITEM_COMPARISON_FIELDS event.
   */
  public function onOrderItemComparison(OrderItemComparisonFieldsEvent $event) {
    $fields = $event->getComparisonFields(); // Fetch the existing comparison fields.
    $order_item = $event->getOrderItem();

    if($order_item->bundle() == 'my_special_order_item_type'){
      // This order item can only be combined if my extra custom field is the same.
      // Add extra field to the comparison array.
      $fields[] = 'field_order_item_custom';
    }
    $event->setComparisonFields($fields);
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    $events[CartEvents::ORDER_ITEM_COMPARISON_FIELDS][] = ['onOrderItemComparison'];
    return $events;
  }
}
                        
                    

Workflows & transitions

Workflow

Why workflows?

Commerce 1.x
  • Statuses indicate multiple concepts
  • Doesn't enforce for the status to change sequentially
  • There is also no way to express rules
    • "only completed orders can be refunded"
    • "completed orders can’t be sent back to checkout"
Source: https://drupalcommerce.org/blog/43169/commerce-2x-stories-workflows

Solution on API level

Introduced the concept of a workflow based on the State machine

  • A collection of states
  • And transitions ("from" states, "to" state)
  • Workflow groups (order workflows, payment workflows, ...)

Defining a workflow

Drupal\[YOUR MODULE]\[YOUR MODULE].workflows.yml
                        
order_with_backlog:
  id: order_with_backlog
  group: commerce_order
  label: 'Default, with backlog functionality'
  states:
    draft:
      label: Draft
    backlog:
      label: Backlog
    completed:
      label: Completed
    canceled:
      label: Canceled
  transitions:
    place:
      label: 'Place order'
      from: [draft]
      to: completed
    in_backlog:
      label: 'In backlog'
      from: [draft]
      to: backlog
    backlog_to_order:
      label: 'Backlog to order'
      from: [backlog]
      to: draft
    cancel:
      label: 'Cancel order'
      from: [draft]
      to:   canceled
                        
                    

Associating the Order Type with the Workflow

Workflow

Commerce contrib

Contrib

Could come in handy

That's all folks!

Thank you for your attention, are there any questions?

Clap clap