We're in the midst of a Commerce 2 build-out for a client, and a key requirement was to preserve their quantity pricing rules. With thousands of products, and different pricing rules for each one, they need the price for each item in the cart adjusted to the appropriate price for the quantity purchased. When validating our plan for Drupal Commerce 2, we stumbled upon some examples of a custom price resolver, and knew this would work perfectly for the need.

Planning out the functionality

This past week was time for the implementation, and in addition to creating the new Price Resolver, we had to decide where to store the pricing data. The previous solution used a custom SQL lookup table, which they kept up-to-date from their back office Sage accounting system through an hourly CSV import.

We could, at worst, follow the same pattern -- query a table using the product SKU. But then we would have to maintain that table ourselves, have a bunch more custom code in place. Given that we were already using Drupal 8's awesome Migrate module to populate the site, we looked further for how we might best implement this using standard Drupal practices.

Commerce 2 has two levels of entities representing products -- the "Product" entity with the text, photos, description, title, and the "Product Variation" which contains the SKU, the price, and specific attribute values. Both use the standard Drupal Field API, so we can easily add fields to either. Why not add a multi-value field for the price breaks?

However, each price break does have two components: a "Threshold" value for maximum quantity valid for a price, and the price itself. We don't want something heavy like paragraphs or an entity reference here -- creating a custom field type makes the most sense.

So our task list to accomplish this became the following:

  1. Create a custom field type for the price break, along with supporting field widget and formatter.
  2. Add an "unlimited" value instance of that field to the default product variation bundle.
  3. Create a custom price resolver that acts upon this field.
  4. Migrate the CSV price-break data into the price break fields (covered in a later blog post).

There's a couple tools that make this process go very quickly: Drupal Console, and PHPStorm. Drupal Console can quickly generate modules, plugins, and more, while PHPStorm is an incredibly smart IDE (development environment) that helps you write the code correctly the first time, with helpful type-aheads that show function header parameters, automatically add "use" statements as you go, and more.

Create a custom Quantity Pricebreak field type

First up, the field type. We need a module to put our custom Commerce functionality, so let's create one:

> drupal generate:module

 This project's code name is lbe, so we created a module called "lbe_commerce", with a dependency on commerce_price.

Next, generate the field type plugins:

> drupal generate:plugin:field

... Enter the newly created module and "ProductPriceBreakItem" for the field type plugin class name. Give it a reasonable name, id, and description. We made the widget class "ProductPriceBreakWidget" and the formatter "ProductPriceBreakFormatter".

When we were done, we had a directory structure that looked like this:

New module directory structure, with field plugin skeleton

New module directory structure, with field plugin skeleton

Product Price Break Field Type

The field type plugin is the first thing to get in place. Drupal Console creates this file for you, you just need to fill in the necessary methods. It actually creates more than you need -- you can strip out several of the methods, because we don't need a field settings form for this custom field type. (If we wanted to publish the module on Drupal.org, we might want to create settings for whether the threshold for a price is the bottom value or the top value of threshold, perhaps -- but for the time being we're keeping this as simple as possible).

So we only need these methods:

  • propertyDefinitions()
  • schema()
  • generateSampleValue()
  • isEmpty()

There are a lot of examples of creating custom fields around the web, but very few were clear on what types were available for propertyDefinitions and schema -- it took a bit of digging and experimenting to find types that worked.

We ended up with:

  public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
    // Prevent early t() calls by using the TranslatableMarkup.
    $properties['threshold'] = DataDefinition::create('integer')
      ->setLabel(new TranslatableMarkup('Threshold'));
    $properties['price'] = DataDefinition::create('string')
      ->setLabel(new TranslatableMarkup('Price'));
    return $properties;
  }

... two fields, threshold and price. For this client, quantity is always an integer. We might've chosen numeric for price, but PriceItem in commerce_price uses strings, so we figured we would follow suit.

We considered extending commerce_price's PriceItem field type, but then we would end up with more configuration to do. We thought of trying to use a PriceItem field in the definitions, but it seemed like we would need to handle the schema ourselves anyway, and just seemed more complicated than needed -- if there is a simple way to do this, I would love to hear about it -- please leave a comment below!

Next up, the Schema:

  public static function schema(FieldStorageDefinitionInterface $field_definition) {
    $schema = [
      'columns' => [
        'threshold' => [
          'type' => 'int',
        ],
        'price' => [
          'type' => 'numeric',
          'precision' => 19,
          'scale' => 6,
        ],
      ],
    ];
    return $schema;
  }

Simple enough schema -- an integer, and the same numeric field definition that commerce_price uses.

  public static function generateSampleValue(FieldDefinitionInterface $field_definition) {
    $values['threshold'] = rand(1,999999);
    $values['price'] = rand(10,10000) / 100;
    return $values;
  }

Our compound field has two different values now, so for tests or devel generate to work, we need to populate random values for those fields.

Finally, isEmpty():

  public function isEmpty() {
    $value = $this->get('threshold')->getValue();
    return $value === NULL || $value === "" || $value === 0;
  }

... if this method returns TRUE, the field is considered empty and that instance will get removed.

That's pretty much it! With the field type created, and this module enabled, you can add the field anywhere you add fields to an entity type.

On to the Widget...

Product Price Break Field Widget

Each field type needs at least one widget that can be used for editing values. Our custom widget needs to provide the form for filling out the two values in the custom field type -- threshold and price. Drupal Console provides a bunch of other method boilerplate for settings forms, etc. but most of these are optional.

We really only need to implement a single method: formElement(). This method returns the Form API fields for the editing form, so it ends up being just as simple as the field type methods:

public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
  $element += [
    '#type' => 'fieldset',
  ];
  $element['threshold'] = [
    '#type' => 'number',
    '#title' => t('Threshold'),
    '#default_value' => isset($items[$delta]->threshold) ? $items[$delta]->threshold : NULL,
    '#size' => 10,
    '#placeholder' => $this->getSetting('placeholder'),
    '#min' => 0,
    '#step' => 1,
  ];
  $element['price'] = [
    '#type' => 'textfield',
    '#title' => t('Price'),
    '#default_value' => isset($items[$delta]->price) ? $items[$delta]->price : NULL,
    '#size' => 10,
    '#placeholder' => $this->getSetting('placeholder'),
    '#maxlength' => 20,
  ];
  return $element;
}

This ends up being a simple widget that looks like this on the product variation form:

To finish up our custom price break field type, we need a field formatter.

Product Price Break Field Formatter

The Formatter is responsible for displaying the field. The only needed method is viewElements(), which renders all instances of the field. In this method we call another method to render each one. For now, this is really basic and ugly -- we'll come back and theme it later.

Here's the code:

public function viewElements(FieldItemListInterface $items, $langcode) {
  $elements = [];
  foreach ($items as $delta => $item) {
    $elements[$delta] = ['#markup' => $this->viewValue($item)];
  }
  return $elements;
}
protected function viewValue(FieldItemInterface $item) {
  return nl2br('Up to: '. Html::escape($item->threshold). ' Price: '. Html::escape($item->price));
}

 ... and that's it! We have our custom field built. It currently looks like this:

Qty Price Breaks on a product add to cart form

Qty Price Breaks on a product add to cart form

Next we add it to the product variation...

Add a product price break field to the product variation type

  1. Under Commerce -> Configuration -> Products -> Product Variations, on the Default row's operations button, go to "Manage Fields".
  2. Click "Add Field".
  3. In the "Add a new field" dropdown, under General select "Product price break".
  4. Add the label, and verify the machine name -- we chose "Qty Price Breaks", as field_qty_price_breaks.
  5. In "Allowed number of values", select Unlimited.

That's it for the widget, data storage, and everything! We don't even need to go near the database. The custom price break is now available on product variations.

Create the Custom Price Resolver

Now comes the fun part, the Price Resolver. The price resolver is implemented as a tagged Drupal Service, so the quick way to generate it is:

> drupal generate:service

We called it QtyPriceResolver, and put it in lbe_commerce/src/Resolvers/QtyPriceResolver.php.

A Drupal service is registered in a modules' .services.yml file, so here's what we ended up with in lbe_commerce.services.yml:

services:
  lbe_commerce.qty_price_resolver:
    class: Drupal\lbe_commerce\Resolvers\QtyPriceResolver
    arguments: []
    tags:
      - { name: commerce_price.price_resolver, priority: 600 }

The crucial part here is the tag -- by registering the service as a "commerce_price.price_resolver", this service will get called any time the price of a product is evaluated. The Priority is used to determine which order resolvers get called -- the first one to return a price sets the price for that product, and any other resolvers get skipped.

Our service class needs to implement \Drupal\commerce_price\Resolver\PriceResolverInterface. There is one crucial method to implement: resolve(). Ours looks like this:

public function resolve(PurchasableEntityInterface $entity, $quantity, Context $context) {
  if (isset($entity->field_qty_price_breaks) && !$entity->field_qty_price_breaks->isEmpty()) {
    foreach ($entity->field_qty_price_breaks as $price_break) {
      if ($quantity <= $price_break->threshold) {
        if (!isset($current_pricebreak) || $current_pricebreak->threshold > $price_break->threshold) {
          $current_pricebreak = $price_break;
        }
      }
    }
    if (isset($current_pricebreak)) {
      return new Price($current_pricebreak->price, 'USD');
    }
  }
}

The key things to notice here is that we have hard-coded this to the field machine name we created in the product variation -- field_qty_price_breaks. If we were writing this more generally, we would iterate through the fields of the product variation looking for a ProductPriceBreak field type -- this is one shortcut we took. The other thing to notice is the return value -- we need to either return a \Drupal\commerce_price\Price object, or void. Because we're not storing the currency code, we always return "USD" as the currency code.

And with that, we have fully implemented the custom quantity price discount system! All that's needed is the actual pricing data.

In the next article, we'll show a slick trick for migrating a set of pricing thresholds from a CSV into multiple price break fields...

 

Permalink

Cool!
In my mind it worth to built a module.
Thanks for sharing!

Add new comment

The content of this field is kept private and will not be shown publicly.

Filtered HTML

  • Web page addresses and email addresses turn into links automatically.
  • Allowed HTML tags: <a href hreflang> <em> <strong> <blockquote cite> <cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h1> <h2 id> <h3 id> <h4 id> <h5 id> <p> <br> <img src alt height width>
  • Lines and paragraphs break automatically.
CAPTCHA
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.