Profile picture for user admin
Daniel Sipos
22 Nov 2021

The Drupal commerce ecosystem, and especially architecture, is both rich and flexible enough to allow us to create some really powerful things. Much comes out of the box that, with a few bits of tweaking here and there, makes our web-shop ready for shipping. Boy…do I love a good pun.

But even more, the architecture that heavily relies on plugins makes it so that when that is not enough, we can quickly write our own bits of integrating code to deliver those missing pieces. So, about such an example I want to talk about today, namely, how we can create a promotion that, when buyers are eligible, will ensure the shipping price does not go over a specific amount. Imagine the following use cases:

  • you have a shipping service with a flat rate in your country but varying rates in other countries, or
  • you have a temporary offer by which you want to knock down all shipping costs to a certain amount, or
  • you have coupons that entitle some users to a given shipping rate.

Of course, the main premise here is that your shipping costs are variable and depend on the things people buy (such as weight and stuff). Because otherwise I can immediately refer you back up to all the things Drupal commerce can do out of the box. Namely, creating an offer type that reduces the shipping rate by either a percentage amount or by a fixed amount:

Image
Drupal commerce shipment fixed off promotion

Today, however, what we are interested in is an offer type that ensures that the shipping cost doesn’t go higher than a certain value. Something like this:

Image
Drupal commerce shipment maximum amount promotion

So, if you check in your current Drupal commerce installation, the offer type from the screenshot above doesn’t exist yet. But, it’s easy to create. So let’s get to it.

First, a little bit of theory.

The promotion offer plugins implement PromotionOfferInterface with its most important method: apply(). The latter is responsible for taking an entity and applying the offer onto it. Shocker. I highly recommend you check out the code for all the existing ones and find inspiration from there. Always the best source to learn from.

Typically, promotion offers are applied to something. For example, shipment costs, the order total as a whole, individual order item prices, etc. So for this reason, we also have interfaces and base classes for the plugins that deal specifically with a type of offer “target”. For example, we have OrderPromotionOfferInterface and it’s corresponding OrderPromotionOfferBase that deal with orders. We also have similar for order items, some others, but finally, what we care about is the ShipmentPromotionOfferInterface and ShipmentPromotionOfferBase, which we will be using.

The reason for these base classes is to provide some specific logic that deals with the type of offer “target”. For instance, when dealing with shipping, we can condition the plugin to a given shipping method. So the base class takes care of the form and that logic already for us. All the plugin has to do is handle the actual offer application and potentially the configuration form for it (as needed).

OK, now that we have a bit of background on where in the code we need to look for inspiration, let’s create our own plugin called ShipmentFixedAmount, which will extend from ShipmentPromotionOfferBase. And we start with the plugin annotation:

/**
 * Sets the shipment amount to a fixed value.
 *
 * @CommercePromotionOffer(
 *   id = "shipment_fixed_amount",
 *   label = @Translation("Maximum amount on the shipment"),
 *   entity_type = "commerce_order"
 * )
 */
class ShipmentFixedAmount extends ShipmentPromotionOfferBase {

Nothing major here, except maybe the confusing entity_type key where we specify the type of entity we want passed to the apply() method of our plugin. We won’t actually deal with the latter because the parent class will do so. But we do want the commerce_order entity there. Instead, we have an applyToShipment() method called by the parent class and which gives us the shipment entity to apply the offer onto. Check out the ShipmentPromotionOfferBase::apply() method to better understand how this works.

Next up, we need a configuration form whereby the user can specify what is the amount to max out the shipping cost at. Drupal commerce comes with some traits that could help with this, but in our case don’t. But you should check out some of them for future reference, as you may be able to use them directly: FixedAmountOffTrait, PercentageOffTrait and the like. But alas, our form will have some different wording so we need to recreate it. Not a biggie, and here are the first few methods of our plugin, specifically that deal with this configuration of the amount by the site manager:

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration() {
    return [
        'amount' => NULL,
      ] + parent::defaultConfiguration();
  }

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

    $amount = $this->configuration['amount'];
    // A bug in the plugin_select form element causes $amount to be incomplete.
    if (isset($amount) && !isset($amount['number'], $amount['currency_code'])) {
      $amount = NULL;
    }

    $form['amount'] = [
      '#type' => 'commerce_price',
      '#title' => $this->t('Maximum amount'),
      '#default_value' => $amount,
      '#required' => TRUE,
      '#weight' => -1,
    ];

    return $form;
  }

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

    if (!$form_state->getErrors()) {
      $values = $form_state->getValue($form['#parents']);
      $this->configuration['amount'] = $values['amount'];
    }
  }

  /**
   * Gets the offer amount.
   *
   * @return \Drupal\commerce_price\Price|null
   *   The amount, or NULL if unknown.
   */
  protected function getAmount() {
    if (!empty($this->configuration['amount'])) {
      $amount = $this->configuration['amount'];
      return new Price($amount['number'], $amount['currency_code']);
    }
  }

Apart from getAmount() which is a simple helper, the rest are implementations of the configurable plugin interface with which we should be a bit familiar already. And you can see that it’s very similar to FixedAmountOffTrait.

Finally, for the actual business logic, we need to implement the applyToShipment() method to apply our offer to the shipment found in the order:

  /**
   * {@inheritdoc}
   */
  public function applyToShipment(ShipmentInterface $shipment, PromotionInterface $promotion) {
    $amount = $this->getAmount();
    if ($amount->getCurrencyCode() != $shipment->getAmount()->getCurrencyCode()) {
      return;
    }

    $current_amount = $shipment->getAdjustedAmount();
    if ($current_amount->lessThanOrEqual($amount)) {
      // If it's already the same amount, do nothing.
      return;
    }

    // Calculate the amount that needs to be subtracted.
    $to_subtract = $current_amount->subtract($amount);

    $shipment->addAdjustment(new Adjustment([
      'type' => 'shipping_promotion',
      'label' => $promotion->getDisplayName() ?: $this->t('Discount'),
      'amount' => $to_subtract->multiply('-1'),
      'source_id' => $promotion->id(),
      'included' => $this->isDisplayInclusive(),
    ]));
  }

So what is going on here?

As you can see we get an instance of the $shipment object onto which we need to set an Adjustment. This is the Commerce way of applying changes to amounts so that they can then be calculated together later (and broken back out if needed).

First we check that the currency is the same as the one on the shipment. We don’t want to deal with currency conversions here. We return if not. Then we quickly check if the actual shipment amount is not already smaller or the same as the one we configured. If it is, again, we return without doing anything. Nothing left to do right?

And finally, we perform the big operation: we determine the difference between the shipping amount and the configured one, so that we know how much we need to subtract. And we do so by adding an adjustment to the shipping (in the negative, hence the $to_subtract->multiply('-1')). And that’s it.

After a good ol’ fashion cache clear, you can create a Promotion using this new offer type, just like in the screenshot above. And when users are eligible to this promotion, they will see a discount on their order equal to the difference between the real shipping amount of their order and the maximum we want to limit this amount to via the promotion. So if the shipping normally costs 10 EUR, but you configured the offer to max out the cost at 7 EUR, users will see a discount of 3 EUR on their order checkout. Which effectively gives them a total cost of 7 EUR on their order.

Phew, long story, but I bet you’ll write this code up in 5 minutes now that you know how and understand where to look.

Hope this helps.

Profile picture for user admin

Daniel Sipos

CEO @ Web Omelette

Danny founded WEBOMELETTE in 2012 as a passion project, mostly writing about Drupal problems he faced day to day, as well as about new technologies and things that he thought other developers would find useful. Now he now manages a team of developers and designers, delivering quality products that make businesses successful.

Contact us

Add new comment