diff --git a/modules/order/src/OrderRefresh.php b/modules/order/src/OrderRefresh.php
index 3eac869d..6c47b039 100644
--- a/modules/order/src/OrderRefresh.php
+++ b/modules/order/src/OrderRefresh.php
@@ -3,6 +3,7 @@
 namespace Drupal\commerce_order;
 
 use Drupal\commerce\Context;
+use Drupal\commerce_price\Calculator;
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\commerce_order\Entity\OrderInterface;
 use Drupal\commerce_order\Entity\OrderType;
@@ -166,11 +167,18 @@ class OrderRefresh implements OrderRefreshInterface {
 
     foreach ($order->getItems() as $order_item) {
       if ($order_item->hasTranslationChanges()) {
-        // Remove the order that was set above, to avoid
-        // crashes during the entity save process.
-        $order_item->order_id->entity = NULL;
-        $order_item->setChangedTime($current_time);
-        $order_item->save();
+        // Remove order items which had their quantities set to 0.
+        if (Calculator::compare($order_item->getQuantity(), '0') === 0) {
+          $order->removeItem($order_item);
+          $order_item->delete();
+        }
+        else {
+          // Remove the order that was set above, to avoid
+          // crashes during the entity save process.
+          $order_item->order_id->entity = NULL;
+          $order_item->setChangedTime($current_time);
+          $order_item->save();
+        }
       }
     }
   }
diff --git a/modules/order/src/Plugin/Commerce/Condition/PurchasedEntityConditionBase.php b/modules/order/src/Plugin/Commerce/Condition/PurchasedEntityConditionBase.php
index 7a51619a..6c65a2f3 100644
--- a/modules/order/src/Plugin/Commerce/Condition/PurchasedEntityConditionBase.php
+++ b/modules/order/src/Plugin/Commerce/Condition/PurchasedEntityConditionBase.php
@@ -4,13 +4,14 @@ namespace Drupal\commerce_order\Plugin\Commerce\Condition;
 
 use Drupal\commerce\EntityUuidMapperInterface;
 use Drupal\commerce\Plugin\Commerce\Condition\ConditionBase;
+use Drupal\commerce\Plugin\Commerce\Condition\PurchasableEntityConditionInterface;
 use Drupal\commerce\PurchasableEntityInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
-abstract class PurchasedEntityConditionBase extends ConditionBase implements ContainerFactoryPluginInterface {
+abstract class PurchasedEntityConditionBase extends ConditionBase implements PurchasableEntityConditionInterface, ContainerFactoryPluginInterface {
 
   /**
    * The entity type manager.
@@ -134,4 +135,23 @@ abstract class PurchasedEntityConditionBase extends ConditionBase implements Con
       in_array($purchasable_entity->uuid(), $this->configuration['entities'], TRUE);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getPurchasableEntityIds() {
+    return $this->entityUuidMapper->mapToIds($this->getPurchasableEntityType(), $this->configuration['entities']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPurchasableEntities() {
+    if ($entity_ids = $this->getPurchasableEntityIds()) {
+      $storage = $this->entityTypeManager->getStorage($this->getPurchasableEntityType());
+      $entities = $storage->loadMultiple($entity_ids);
+    }
+
+    return $entities ?? [];
+  }
+
 }
diff --git a/modules/product/src/Plugin/Commerce/Condition/OrderItemProduct.php b/modules/product/src/Plugin/Commerce/Condition/OrderItemProduct.php
index 9382f10c..b1a0106b 100644
--- a/modules/product/src/Plugin/Commerce/Condition/OrderItemProduct.php
+++ b/modules/product/src/Plugin/Commerce/Condition/OrderItemProduct.php
@@ -4,6 +4,7 @@ namespace Drupal\commerce_product\Plugin\Commerce\Condition;
 
 use Drupal\commerce\EntityUuidMapperInterface;
 use Drupal\commerce\Plugin\Commerce\Condition\ConditionBase;
+use Drupal\commerce\Plugin\Commerce\Condition\PurchasableEntityConditionInterface;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
@@ -21,10 +22,17 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  *   weight = -1,
  * )
  */
-class OrderItemProduct extends ConditionBase implements ContainerFactoryPluginInterface {
+class OrderItemProduct extends ConditionBase implements PurchasableEntityConditionInterface, ContainerFactoryPluginInterface {
 
   use ProductTrait;
 
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
   /**
    * Constructs a new OrderItemProduct object.
    *
@@ -43,6 +51,7 @@ class OrderItemProduct extends ConditionBase implements ContainerFactoryPluginIn
   public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityUuidMapperInterface $entity_uuid_mapper) {
     parent::__construct($configuration, $plugin_id, $plugin_definition);
 
+    $this->entityTypeManager = $entity_type_manager;
     $this->productStorage = $entity_type_manager->getStorage('commerce_product');
     $this->entityUuidMapper = $entity_uuid_mapper;
   }
@@ -77,4 +86,33 @@ class OrderItemProduct extends ConditionBase implements ContainerFactoryPluginIn
     return in_array($purchased_entity->getProductId(), $product_ids);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getPurchasableEntityIds() {
+    $variation_ids = [];
+
+    $product_ids = $this->getProductIds();
+    if (!empty($product_ids)) {
+      foreach ($this->productStorage->loadMultiple($product_ids) as $product) {
+        /** @var \Drupal\commerce_product\Entity\ProductInterface $product */
+        $variation_ids += $product->getVariationIds();
+      }
+    }
+
+    return array_values($variation_ids);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPurchasableEntities() {
+    if ($entity_ids = $this->getPurchasableEntityIds()) {
+      $storage = $this->entityTypeManager->getStorage('commerce_product_variation');
+      $entities = $storage->loadMultiple($entity_ids);
+    }
+
+    return $entities ?? [];
+  }
+
 }
diff --git a/modules/promotion/commerce_promotion.services.yml b/modules/promotion/commerce_promotion.services.yml
index b25e6d43..ee0442b1 100644
--- a/modules/promotion/commerce_promotion.services.yml
+++ b/modules/promotion/commerce_promotion.services.yml
@@ -3,6 +3,12 @@ services:
     class: Drupal\commerce_promotion\PromotionOfferManager
     arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@entity_type.manager']
 
+  commerce_promotion.early_order_processor:
+    class: Drupal\commerce_promotion\EarlyOrderProcessor
+    arguments: ['@entity_type.manager']
+    tags:
+      - { name: commerce_order.order_processor, priority: 1000}
+
   commerce_promotion.promotion_order_processor:
     class: Drupal\commerce_promotion\PromotionOrderProcessor
     arguments: ['@entity_type.manager', '@language_manager']
diff --git a/modules/promotion/src/EarlyOrderProcessor.php b/modules/promotion/src/EarlyOrderProcessor.php
new file mode 100644
index 00000000..d1dae197
--- /dev/null
+++ b/modules/promotion/src/EarlyOrderProcessor.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\commerce_promotion;
+
+use Drupal\commerce_order\Entity\OrderInterface;
+use Drupal\commerce_order\OrderProcessorInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+
+/**
+ * Allows promotions to clear their changes during the order refresh process.
+ */
+class EarlyOrderProcessor implements OrderProcessorInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Constructs a new EarlyOrderProcessor object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function process(OrderInterface $order) {
+    // Check to see if there are any promotion adjustments present on the order
+    // that need to be cleared.
+    $promotion_ids = [];
+    foreach ($order->collectAdjustments(['promotion']) as $adjustment) {
+      if (empty($adjustment->getSourceId())) {
+        continue;
+      }
+      $promotion_ids[] = $adjustment->getSourceId();
+    }
+
+    // No promotions were found, stop here.
+    if (!$promotion_ids) {
+      return;
+    }
+
+    $promotions = $this->entityTypeManager->getStorage('commerce_promotion')->loadMultiple($promotion_ids);
+    /** @var \Drupal\commerce_promotion\Entity\PromotionInterface $promotion */
+    foreach ($promotions as $promotion) {
+      $promotion->clear($order);
+    }
+  }
+
+}
diff --git a/modules/promotion/src/Entity/Promotion.php b/modules/promotion/src/Entity/Promotion.php
index db405605..9f8c885c 100644
--- a/modules/promotion/src/Entity/Promotion.php
+++ b/modules/promotion/src/Entity/Promotion.php
@@ -577,6 +577,21 @@ class Promotion extends CommerceContentEntityBase implements PromotionInterface
     }
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function clear(OrderInterface $order) {
+    $offer = $this->getOffer();
+    if ($offer instanceof OrderItemPromotionOfferInterface) {
+      foreach ($order->getItems() as $order_item) {
+        $offer->clear($order_item, $this);
+      }
+    }
+    else {
+      $offer->clear($order, $this);
+    }
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/modules/promotion/src/Entity/PromotionInterface.php b/modules/promotion/src/Entity/PromotionInterface.php
index 13b0835c..936f6f4e 100644
--- a/modules/promotion/src/Entity/PromotionInterface.php
+++ b/modules/promotion/src/Entity/PromotionInterface.php
@@ -421,4 +421,12 @@ interface PromotionInterface extends ContentEntityInterface, EntityStoresInterfa
    */
   public function apply(OrderInterface $order);
 
+  /**
+   * Allows a promotion to clean up any modifications done to the given order.
+   *
+   * @param \Drupal\commerce_order\Entity\OrderInterface $order
+   *   The order.
+   */
+  public function clear(OrderInterface $order);
+
 }
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/BuyXGetY.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/BuyXGetY.php
index 6781c5ee..a1f50bc8 100644
--- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/BuyXGetY.php
+++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/BuyXGetY.php
@@ -4,16 +4,21 @@ namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer;
 
 use Drupal\commerce\ConditionGroup;
 use Drupal\commerce\ConditionManagerInterface;
+use Drupal\commerce\Context;
+use Drupal\commerce\Plugin\Commerce\Condition\PurchasableEntityConditionInterface;
+use Drupal\commerce\PurchasableEntityInterface;
 use Drupal\commerce_order\Adjustment;
 use Drupal\commerce_order\Entity\OrderItemInterface;
 use Drupal\commerce_order\PriceSplitterInterface;
 use Drupal\commerce_price\Calculator;
 use Drupal\commerce_price\Price;
+use Drupal\commerce_price\Resolver\ChainPriceResolverInterface;
 use Drupal\commerce_price\RounderInterface;
 use Drupal\commerce_promotion\Entity\PromotionInterface;
 use Drupal\Component\Utility\Html;
 use Drupal\Component\Utility\NestedArray;
 use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Form\FormStateInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
@@ -44,6 +49,20 @@ class BuyXGetY extends OrderPromotionOfferBase {
    */
   protected $conditionManager;
 
+  /**
+   * The chain base price resolver.
+   *
+   * @var \Drupal\commerce_price\Resolver\ChainPriceResolverInterface
+   */
+  protected $chainPriceResolver;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
   /**
    * Constructs a new BuyXGetY object.
    *
@@ -59,11 +78,17 @@ class BuyXGetY extends OrderPromotionOfferBase {
    *   The splitter.
    * @param \Drupal\commerce\ConditionManagerInterface $condition_manager
    *   The condition manager.
+   * @param \Drupal\commerce_price\Resolver\ChainPriceResolverInterface $chain_price_resolver
+   *   The chain price resolver.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
    */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition, RounderInterface $rounder, PriceSplitterInterface $splitter, ConditionManagerInterface $condition_manager) {
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, RounderInterface $rounder, PriceSplitterInterface $splitter, ConditionManagerInterface $condition_manager, ChainPriceResolverInterface $chain_price_resolver, EntityTypeManagerInterface $entity_type_manager) {
     parent::__construct($configuration, $plugin_id, $plugin_definition, $rounder, $splitter);
 
     $this->conditionManager = $condition_manager;
+    $this->chainPriceResolver = $chain_price_resolver;
+    $this->entityTypeManager = $entity_type_manager;
   }
 
   /**
@@ -76,7 +101,9 @@ class BuyXGetY extends OrderPromotionOfferBase {
       $plugin_definition,
       $container->get('commerce_price.rounder'),
       $container->get('commerce_order.price_splitter'),
-      $container->get('plugin.manager.commerce_condition')
+      $container->get('plugin.manager.commerce_condition'),
+      $container->get('commerce_price.chain_price_resolver'),
+      $container->get('entity_type.manager')
     );
   }
 
@@ -89,9 +116,11 @@ class BuyXGetY extends OrderPromotionOfferBase {
       'buy_conditions' => [],
       'get_quantity' => 1,
       'get_conditions' => [],
+      'get_auto_add' => FALSE,
       'offer_type' => 'percentage',
       'offer_percentage' => '0',
       'offer_amount' => NULL,
+      'offer_limit' => '0',
     ] + parent::defaultConfiguration();
   }
 
@@ -140,7 +169,23 @@ class BuyXGetY extends OrderPromotionOfferBase {
       '#entity_types' => ['commerce_order_item'],
       '#default_value' => $this->configuration['get_conditions'],
     ];
-
+    $states = $this->getAutoAddStatesVisibility();
+    $form['get']['auto_add_help'] = [
+      '#type' => 'item',
+      '#title' => $this->t('Behavior'),
+      '#states' => [
+        'visible' => [$states],
+      ],
+    ];
+    $form['get']['auto_add'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t("Automatically add the offer product to the cart if it isn't in it already"),
+      '#description' => $this->t('This behavior will only work when a single product variation (or a single product with only one variation) is specified.'),
+      '#default_value' => $this->configuration['get_auto_add'],
+      '#states' => [
+        'visible' => [$states],
+      ],
+    ];
     $parents = array_merge($form['#parents'], ['offer', 'type']);
     $selected_offer_type = NestedArray::getValue($form_state->getUserInput(), $parents);
     $selected_offer_type = $selected_offer_type ?: $this->configuration['offer_type'];
@@ -188,6 +233,34 @@ class BuyXGetY extends OrderPromotionOfferBase {
       ];
     }
 
+    $form['limit'] = [
+      '#type' => 'fieldset',
+      '#title' => $this->t('Offer limit'),
+      '#description' => $this->t('The number of times this offer can apply to the same order.'),
+      '#collapsible' => FALSE,
+    ];
+    $form['limit']['amount'] = [
+      '#type' => 'radios',
+      '#title' => $this->t('Limited to'),
+      '#title_display' => 'invisible',
+      '#options' => [
+        0 => $this->t('Unlimited'),
+        1 => $this->t('Limited number of uses'),
+      ],
+      '#default_value' => $this->configuration['offer_limit'] ? 1 : 0,
+    ];
+    $form['limit']['offer_limit'] = [
+      '#type' => 'number',
+      '#title' => $this->t('Number of uses'),
+      '#title_display' => 'invisible',
+      '#default_value' => $this->configuration['offer_limit'] ?: 1,
+      '#states' => [
+        'invisible' => [
+          ':input[name="offer[0][target_plugin_configuration][order_buy_x_get_y][limit][amount]"]' => ['value' => 0],
+        ],
+      ],
+    ];
+
     return $form;
   }
 
@@ -210,6 +283,30 @@ class BuyXGetY extends OrderPromotionOfferBase {
     if ($values['offer']['type'] == 'percentage' && empty($values['offer']['percentage'])) {
       $form_state->setError($form, $this->t('Percentage must be a positive number.'));
     }
+
+    if ($values['get']['auto_add']) {
+      // Ensure that at least one compatible condition enabled.
+      $valid_condition_ids = array_keys($this->getPurchasableEntityConditions());
+      $has_valid_condition = FALSE;
+      foreach ($values['get']['conditions']['products'] as $condition_id => $condition_values) {
+        if (in_array($condition_id, $valid_condition_ids) && (bool) $condition_values['enable']) {
+          $has_valid_condition = TRUE;
+          break;
+        }
+      }
+      // We can't automatically add the "get" product if no valid conditions are
+      // selected.
+      if (!$has_valid_condition) {
+        $values['get']['auto_add'] = FALSE;
+        $form_state->setValue($form['#parents'], $values);
+        return;
+      }
+
+      // Ensure that the offer is a 100% discount.
+      if ($values['offer']['type'] === 'fixed_amount' || ($values['offer']['type'] === 'percentage' && $values['offer']['percentage'] != '100')) {
+        $form_state->setError($form['offer'], $this->t('The "auto-add" offer can only be enabled for products that discounted 100%.'));
+      }
+    }
   }
 
   /**
@@ -224,6 +321,7 @@ class BuyXGetY extends OrderPromotionOfferBase {
       $this->configuration['buy_conditions'] = $values['buy']['conditions'];
       $this->configuration['get_quantity'] = $values['get']['quantity'];
       $this->configuration['get_conditions'] = $values['get']['conditions'];
+      $this->configuration['get_auto_add'] = $values['get']['auto_add'];
       $this->configuration['offer_type'] = $values['offer']['type'];
       if ($this->configuration['offer_type'] == 'percentage') {
         $this->configuration['offer_percentage'] = Calculator::divide((string) $values['offer']['percentage'], '100');
@@ -233,6 +331,7 @@ class BuyXGetY extends OrderPromotionOfferBase {
         $this->configuration['offer_percentage'] = NULL;
         $this->configuration['offer_amount'] = $values['offer']['amount'];
       }
+      $this->configuration['offer_limit'] = ($values['limit']['amount'] == 0 ? 0 : $values['limit']['offer_limit']);
     }
   }
 
@@ -255,6 +354,40 @@ class BuyXGetY extends OrderPromotionOfferBase {
     }
 
     $get_conditions = $this->buildConditionGroup($this->configuration['get_conditions']);
+    if ($this->configuration['get_auto_add'] && ($get_purchasable_entity = $this->findSinglePurchasableEntity($get_conditions))) {
+      $order_item = $this->findOrCreateOrderItem($get_purchasable_entity, $order_items);
+      $expected_get_quantity = $this->calculateExpectedGetQuantity($buy_quantities, $order_item);
+
+      // If the expected get quantity is non-zero, we need to update the
+      // quantity of the 'get' order item accordingly.
+      if (Calculator::compare($expected_get_quantity, '0') !== 0) {
+        if (Calculator::compare($order_item->getQuantity(), $expected_get_quantity) === -1) {
+          $order_item->setQuantity($expected_get_quantity);
+
+          // Ensure that order items which are 'touched' by this promotion can
+          // not be edited by the customer, either by changing their quantity or
+          // removing them from the cart.
+          $order_item->lock();
+        }
+
+        // Keep track of the quantity that was auto-added to this order item so
+        // we can subtract it (or remove the order item completely) if the buy
+        // conditions are no longer satisfied on the next order refresh.
+        $order_item->setData("promotion:{$promotion->id()}:auto_add_quantity", $expected_get_quantity);
+
+        if ($order_item->isNew()) {
+          $time = $order->getCalculationDate()->format('U');
+          $context = new Context($order->getCustomer(), $order->getStore(), $time);
+          $unit_price = $this->chainPriceResolver->resolve($get_purchasable_entity, $order_item->getQuantity(), $context);
+          $order_item->setUnitPrice($unit_price);
+          $order_item->set('order_id', $order->id());
+          $order_item->save();
+          $order->addItem($order_item);
+          $order_items = $order->getItems();
+        }
+      }
+    }
+
     $get_order_items = $this->selectOrderItems($order_items, $get_conditions, 'ASC');
     $get_quantities = array_map(function (OrderItemInterface $order_item) {
       return $order_item->getQuantity();
@@ -281,6 +414,7 @@ class BuyXGetY extends OrderPromotionOfferBase {
     }
 
     $final_quantities = [];
+    $i = 0;
     while (!empty($buy_quantities)) {
       $selected_buy_quantities = $this->sliceQuantities($buy_quantities, $this->configuration['buy_quantity']);
       if (array_sum($selected_buy_quantities) < $this->configuration['buy_quantity']) {
@@ -292,6 +426,11 @@ class BuyXGetY extends OrderPromotionOfferBase {
       // Merge the selected get quantities into a final list, to ensure that
       // each order item only gets a single adjustment.
       $final_quantities = $this->mergeQuantities($final_quantities, $selected_get_quantities);
+
+      // Determine whether the offer reached its limit.
+      if ($this->configuration['offer_limit'] == ++$i) {
+        break;
+      }
     }
 
     foreach ($final_quantities as $order_item_id => $quantity) {
@@ -303,10 +442,40 @@ class BuyXGetY extends OrderPromotionOfferBase {
         'label' => $promotion->getDisplayName() ?: $this->t('Discount'),
         'amount' => $adjustment_amount->multiply('-1'),
         'source_id' => $promotion->id(),
+        'locked' => TRUE,
       ]));
     }
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function clear(EntityInterface $entity, PromotionInterface $promotion) {
+    parent::clear($entity, $promotion);
+
+    $this->assertEntity($entity);
+    /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
+    $order = $entity;
+
+    // Check if we have any order item whose quantity has been changed by this
+    // promotion, and subtract that amount. If the promotion still applies, the
+    // necessary quantity will be added back in ::apply(). Order items that will
+    // end up with a quantity of 0 will be removed from the order by
+    // \Drupal\commerce_order\OrderRefresh::refresh().
+    if ($this->configuration['get_auto_add']) {
+      $auto_add_order_items = array_filter($order->getItems(), function (OrderItemInterface $order_item) use ($promotion) {
+        return $order_item->getData("promotion:{$promotion->id()}:auto_add_quantity");
+      });
+      foreach ($auto_add_order_items as $order_item) {
+        $order_item->setAdjustments(array_filter($order_item->getAdjustments(), function (Adjustment $adjustment) use ($promotion) {
+          return $adjustment->getSourceId() !== $promotion->id();
+        }));
+        $new_quantity = Calculator::subtract($order_item->getQuantity(), $order_item->getData("promotion:{$promotion->id()}:auto_add_quantity"));
+        $order_item->setQuantity($new_quantity);
+      }
+    }
+  }
+
   /**
    * Builds a condition group for the given condition configuration.
    *
@@ -368,6 +537,142 @@ class BuyXGetY extends OrderPromotionOfferBase {
     return $selected_order_items;
   }
 
+  /**
+   * Finds the configured purchasable entity amongst the given conditions.
+   *
+   * @param \Drupal\commerce\ConditionGroup $conditions
+   *   The condition group.
+   *
+   * @return \Drupal\commerce\PurchasableEntityInterface|null
+   *   The purchasable entity, or NULL if not found in the conditions.
+   */
+  protected function findSinglePurchasableEntity(ConditionGroup $conditions) {
+    foreach ($conditions->getConditions() as $condition) {
+      if ($condition instanceof PurchasableEntityConditionInterface) {
+        $purchasable_entity_ids = $condition->getPurchasableEntityIds();
+        if (count($purchasable_entity_ids) === 1) {
+          $purchasable_entities = $condition->getPurchasableEntities();
+          return reset($purchasable_entities);
+        }
+      }
+    }
+
+    return NULL;
+  }
+
+  /**
+   * Attempt to find the given purchasable entity amongst the given order items.
+   *
+   * If the given purchasable entity isn't referenced by any order item, create
+   * an order item referencing it so we can automatically add it to the order.
+   *
+   * @param \Drupal\commerce\PurchasableEntityInterface $get_purchasable_entity
+   *   The "get" purchasable entity.
+   * @param \Drupal\commerce_order\Entity\OrderItemInterface[] $order_items
+   *   The order items.
+   *
+   * @return \Drupal\commerce_order\Entity\OrderItemInterface
+   *   An order item referencing the given purchasable entity.
+   */
+  protected function findOrCreateOrderItem(PurchasableEntityInterface $get_purchasable_entity, array $order_items) {
+    foreach ($order_items as $order_item) {
+      $purchased_entity = $order_item->getPurchasedEntity();
+      if ($purchased_entity->getEntityTypeId() == $get_purchasable_entity->getEntityTypeId()
+          && $purchased_entity->id() == $get_purchasable_entity->id()) {
+        return $order_item;
+      }
+    }
+
+    /** @var \Drupal\commerce_order\OrderItemStorageInterface $storage */
+    $storage = $this->entityTypeManager->getStorage('commerce_order_item');
+    $order_item = $storage->createFromPurchasableEntity($get_purchasable_entity, [
+      'quantity' => 0,
+    ]);
+    return $order_item;
+  }
+
+  /**
+   * Calculates the expected get quantity.
+   *
+   * @param array $buy_quantities
+   *   An array of buy quantities.
+   * @param \Drupal\commerce_order\Entity\OrderItemInterface $order_item
+   *   The order item.
+   *
+   * @return string
+   *   The expected get quantity.
+   */
+  protected function calculateExpectedGetQuantity(array $buy_quantities, OrderItemInterface $order_item) {
+    $expected_get_quantity = '0';
+
+    // Ensure that any possible "get" quantity already in the order is always
+    // processed last.
+    if (!$order_item->isNew()) {
+      if (isset($buy_quantities[$order_item->id()])) {
+        $quantity = $buy_quantities[$order_item->id()];
+        unset($buy_quantities[$order_item->id()]);
+        $buy_quantities[$order_item->id()] = $quantity;
+      }
+    }
+
+    $i = 0;
+    while (!empty($buy_quantities)) {
+      $this->sliceQuantities($buy_quantities, $this->configuration['buy_quantity']);
+      $expected_get_quantity = Calculator::add($expected_get_quantity, $this->configuration['get_quantity']);
+
+      // Determine whether the offer reached its limit.
+      if ($this->configuration['offer_limit'] == ++$i) {
+        break;
+      }
+
+      // If the "get" purchasable entity is already in the order, we need to
+      // ensure that the already discounted quantity is not counted towards the
+      // buy quantities.
+      if (!$order_item->isNew()) {
+        $buy_quantities = $this->removeQuantities($buy_quantities, [$order_item->id() => $this->configuration['get_quantity']]);
+      }
+
+      if (array_sum($buy_quantities) < $this->configuration['buy_quantity']) {
+        break;
+      }
+    }
+
+    return $expected_get_quantity;
+  }
+
+  /**
+   * Gets the #states visibility array for the 'auto_add' form element.
+   *
+   * @return array
+   *   An array of visibility options for a form element's #state property.
+   */
+  protected function getAutoAddStatesVisibility() {
+    // The 'auto_add' form element has to be shown if _any_ condition that
+    // provides a purchasable entity is enabled for the 'get' conditions. This
+    // means we need to construct a list of OR statements for #states, which
+    // looks like this:
+    // @code
+    // '#states' => [
+    //   'visible' => [
+    //     [':input[name="some_element"]' => ['checked' => TRUE]],
+    //     'or',
+    //     [':input[name="another_element"]' => ['checked' => TRUE]],
+    //     ...
+    //   ],
+    // ],
+    // @endcode
+    $conditions = $this->getPurchasableEntityConditions();
+    $states_visibility = array_map(function ($value) {
+      return [':input[name="offer[0][target_plugin_configuration][order_buy_x_get_y][get][conditions][products][' . $value . '][enable]"]' => ['checked' => TRUE]];
+    }, array_keys($conditions));
+
+    for ($i = 0; $i < count($conditions) - 1; $i++) {
+      array_splice($states_visibility, $i + 1, 0, 'or');
+    }
+
+    return $states_visibility;
+  }
+
   /**
    * Takes a slice from the given quantity list.
    *
@@ -492,4 +797,16 @@ class BuyXGetY extends OrderPromotionOfferBase {
     return $adjustment_amount;
   }
 
+  /**
+   * Gets the purchasable entity condition plugin definitions.
+   *
+   * @return array
+   *   The purchasable entity condition plugin definitions.
+   */
+  protected function getPurchasableEntityConditions() {
+    return array_filter($this->conditionManager->getFilteredDefinitions('commerce_promotion', ['commerce_order_item']), function ($definition) {
+      return is_subclass_of($definition['class'], PurchasableEntityConditionInterface::class);
+    });
+  }
+
 }
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php
index fcab3a5f..7a833a72 100644
--- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php
+++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php
@@ -3,6 +3,7 @@
 namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer;
 
 use Drupal\commerce_price\RounderInterface;
+use Drupal\commerce_promotion\Entity\PromotionInterface;
 use Drupal\Component\Utility\NestedArray;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Form\FormStateInterface;
@@ -108,6 +109,11 @@ abstract class PromotionOfferBase extends PluginBase implements PromotionOfferIn
     return $this->pluginDefinition['entity_type'];
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function clear(EntityInterface $entity, PromotionInterface $promotion) {}
+
   /**
    * Asserts that the given entity is of the expected type.
    *
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php
index 9d54e17b..00ddff25 100644
--- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php
+++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php
@@ -39,4 +39,14 @@ interface PromotionOfferInterface extends ConfigurableInterface, PluginFormInter
    */
   public function apply(EntityInterface $entity, PromotionInterface $promotion);
 
+  /**
+   * Allows an offer to clean up any modifications done to the given entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity.
+   * @param \Drupal\commerce_promotion\Entity\PromotionInterface $promotion
+   *   THe parent promotion.
+   */
+  public function clear(EntityInterface $entity, PromotionInterface $promotion);
+
 }
diff --git a/modules/promotion/tests/src/Kernel/Plugin/Commerce/PromotionOffer/BuyXGetYTest.php b/modules/promotion/tests/src/Kernel/Plugin/Commerce/PromotionOffer/BuyXGetYTest.php
index 398fb287..a729ec97 100644
--- a/modules/promotion/tests/src/Kernel/Plugin/Commerce/PromotionOffer/BuyXGetYTest.php
+++ b/modules/promotion/tests/src/Kernel/Plugin/Commerce/PromotionOffer/BuyXGetYTest.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Tests\commerce_promotion\Kernel\Plugin\Commerce\PromotionOffer;
 
+use Drupal\commerce_order\Adjustment;
 use Drupal\commerce_order\Entity\Order;
 use Drupal\commerce_price\Calculator;
 use Drupal\commerce_price\Price;
@@ -120,7 +121,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     ]);
 
     // Buy 6 "test" products, get 4 hats.
-    $this->promotion = Promotion::create([
+    $promotion = Promotion::create([
       'name' => 'Promotion 1',
       'order_types' => [$this->order->bundle()],
       'stores' => [$this->store->id()],
@@ -155,8 +156,10 @@ class BuyXGetYTest extends OrderKernelTestBase {
           ],
         ],
       ],
-      'status' => FALSE,
+      'status' => TRUE,
     ]);
+    $promotion->save();
+    $this->promotion = $this->reloadEntity($promotion);
   }
 
   /**
@@ -181,14 +184,14 @@ class BuyXGetYTest extends OrderKernelTestBase {
     // Insufficient purchase quantity.
     // Only the first order item is counted (due to the product type condition),
     // and its quantity is too small (2 < 6).
-    $this->promotion->apply($this->order);
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
     $this->assertEmpty($this->order->collectAdjustments());
 
     // Sufficient purchase quantity, but no offer order item found.
     $first_order_item->setQuantity(6);
     $first_order_item->save();
     $this->order->save();
-    $this->promotion->apply($this->order);
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
     $this->assertEmpty($this->order->collectAdjustments());
   }
 
@@ -214,7 +217,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $second_order_item->save();
     $this->order->setItems([$first_order_item, $second_order_item, $third_order_item]);
     $this->order->save();
-    $this->promotion->apply($this->order);
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
     list($first_order_item, $second_order_item, $third_order_item) = $this->order->getItems();
 
     $this->assertCount(0, $first_order_item->getAdjustments());
@@ -227,6 +230,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $this->assertEquals('Discount', $adjustment->getLabel());
     $this->assertEquals(new Price('-3', 'USD'), $adjustment->getAmount());
     $this->assertEquals($this->promotion->id(), $adjustment->getSourceId());
+    $this->assertTrue($adjustment->isLocked());
 
     // Test having two offer order items, one ($third_order_item) reduced
     // completely, the other ($fourth_order_item) reduced partially.
@@ -234,9 +238,8 @@ class BuyXGetYTest extends OrderKernelTestBase {
       'quantity' => '2',
     ]);
     $this->order->addItem($fourth_order_item);
-    $this->order->clearAdjustments();
     $this->order->save();
-    $this->promotion->apply($this->order);
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
     list($first_order_item, $second_order_item, $third_order_item, $fourth_order_item) = $this->order->getItems();
 
     $this->assertCount(0, $first_order_item->getAdjustments());
@@ -250,6 +253,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $this->assertEquals('Discount', $adjustment->getLabel());
     $this->assertEquals(new Price('-3', 'USD'), $adjustment->getAmount());
     $this->assertEquals($this->promotion->id(), $adjustment->getSourceId());
+    $this->assertTrue($adjustment->isLocked());
 
     $adjustments = $fourth_order_item->getAdjustments();
     $adjustment = reset($adjustments);
@@ -257,6 +261,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $this->assertEquals('Discount', $adjustment->getLabel());
     $this->assertEquals(new Price('-1', 'USD'), $adjustment->getAmount());
     $this->assertEquals($this->promotion->id(), $adjustment->getSourceId());
+    $this->assertTrue($adjustment->isLocked());
   }
 
   /**
@@ -291,7 +296,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $second_order_item->save();
     $this->order->setItems([$first_order_item, $second_order_item, $third_order_item]);
     $this->order->save();
-    $this->promotion->apply($this->order);
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
     list($first_order_item, $second_order_item, $third_order_item) = $this->order->getItems();
 
     $this->assertCount(0, $first_order_item->getAdjustments());
@@ -304,6 +309,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $this->assertEquals('Buy X Get Y!', $adjustment->getLabel());
     $this->assertEquals(new Price('-18', 'USD'), $adjustment->getAmount());
     $this->assertEquals($this->promotion->id(), $adjustment->getSourceId());
+    $this->assertTrue($adjustment->isLocked());
 
     // Test having two offer order items, one ($third_order_item) reduced
     // completely, the other ($fourth_order_item) reduced partially.
@@ -311,9 +317,8 @@ class BuyXGetYTest extends OrderKernelTestBase {
       'quantity' => '3',
     ]);
     $this->order->addItem($fourth_order_item);
-    $this->order->clearAdjustments();
     $this->order->save();
-    $this->promotion->apply($this->order);
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
     list($first_order_item, $second_order_item, $third_order_item, $fourth_order_item) = $this->order->getItems();
 
     $this->assertCount(0, $first_order_item->getAdjustments());
@@ -327,6 +332,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $this->assertEquals('Buy X Get Y!', $adjustment->getLabel());
     $this->assertEquals(new Price('-18', 'USD'), $adjustment->getAmount());
     $this->assertEquals($this->promotion->id(), $adjustment->getSourceId());
+    $this->assertTrue($adjustment->isLocked());
 
     $adjustments = $fourth_order_item->getAdjustments();
     $adjustment = reset($adjustments);
@@ -334,6 +340,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $this->assertEquals('Buy X Get Y!', $adjustment->getLabel());
     $this->assertEquals(new Price('-6', 'USD'), $adjustment->getAmount());
     $this->assertEquals($this->promotion->id(), $adjustment->getSourceId());
+    $this->assertTrue($adjustment->isLocked());
   }
 
   /**
@@ -360,7 +367,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $order_item->save();
     $this->order->addItem($order_item);
     $this->order->save();
-    $this->promotion->apply($this->order);
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
     list($order_item) = $this->order->getItems();
 
     $this->assertCount(1, $order_item->getAdjustments());
@@ -405,7 +412,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $third_order_item->save();
     $this->order->setItems([$first_order_item, $second_order_item, $third_order_item]);
     $this->order->save();
-    $this->promotion->apply($this->order);
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
     list($first_order_item, $second_order_item, $third_order_item) = $this->order->getItems();
 
     $this->assertCount(0, $first_order_item->getAdjustments());
@@ -466,7 +473,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $third_order_item->save();
     $this->order->setItems([$first_order_item, $second_order_item, $third_order_item]);
     $this->order->save();
-    $this->promotion->apply($this->order);
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
     list($first_order_item, $second_order_item, $third_order_item) = $this->order->getItems();
 
     $this->assertCount(0, $first_order_item->getAdjustments());
@@ -505,7 +512,7 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $fourth_order_item->save();
     $this->order->setItems([$first_order_item, $second_order_item, $third_order_item, $fourth_order_item]);
     $this->order->save();
-    $this->promotion->apply($this->order);
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
     list($first_order_item, $second_order_item, $third_order_item, $fourth_order_item) = $this->order->getItems();
 
     $this->assertCount(0, $first_order_item->getAdjustments());
@@ -526,4 +533,214 @@ class BuyXGetYTest extends OrderKernelTestBase {
     $this->assertEquals($this->promotion->id(), $adjustment->getSourceId());
   }
 
+  /**
+   * Tests the 'auto-add' offered item capability.
+   *
+   * @covers ::apply
+   */
+  public function testAutoAddOrderItem() {
+    // Configure a "buy 3 of anything, get 1 specific product free" offer.
+    $offer = $this->promotion->getOffer();
+    $offer_configuration = $offer->getConfiguration();
+    // The customer purchases 3 quantities of any product.
+    $offer_configuration['buy_quantity'] = '3';
+    $offer_configuration['buy_conditions'] = [];
+    // The customer receives 1 specific product for free.
+    $offer_configuration['get_quantity'] = '1';
+    $offer_configuration['get_conditions'] = [
+      [
+        'plugin' => 'order_item_purchased_entity:commerce_product_variation',
+        'configuration' => [
+          'entities' => [
+            $this->variations[2]->uuid(),
+          ],
+        ],
+      ],
+    ];
+    $offer_configuration['get_auto_add'] = TRUE;
+    $offer_configuration['offer_type'] = 'percentage';
+    $offer_configuration['offer_percentage'] = '1';
+    $offer_configuration['offer_amount'] = NULL;
+    $offer->setConfiguration($offer_configuration);
+    $this->promotion->setOffer($offer);
+
+    /** @var \Drupal\commerce_order\OrderItemStorageInterface $order_item_storage */
+    $order_item_storage = \Drupal::entityTypeManager()->getStorage('commerce_order_item');
+    // Price of first order item: 10. Matches the first required quantity of the
+    // buy condition.
+    $first_order_item = $order_item_storage->createFromPurchasableEntity($this->variations[0], [
+      'quantity' => '3',
+    ]);
+    $this->order->setItems([$first_order_item]);
+    $this->order->save();
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
+
+    // The offer automatically added a second order item.
+    list($first_order_item, $second_order_item) = $this->order->getItems();
+
+    $this->assertCount(0, $first_order_item->getAdjustments());
+    $this->assertCount(1, $second_order_item->getAdjustments());
+    $this->assertEquals(1, $second_order_item->getQuantity());
+    $this->assertEquals($this->variations[2]->id(), $second_order_item->getPurchasedEntityId());
+    $this->assertAdjustmentPrice($second_order_item->getAdjustments()[0], '-30');
+
+    // Increase the quantity of the "buy" product to 4, the quantity of the
+    // offered product will not change.
+    $first_order_item->setQuantity(4);
+    $this->order->setItems([$first_order_item, $second_order_item]);
+    $this->order->save();
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
+
+    list($first_order_item, $second_order_item) = $this->order->getItems();
+
+    $this->assertCount(0, $first_order_item->getAdjustments());
+    $this->assertEquals(4, $first_order_item->getQuantity());
+    $this->assertCount(1, $second_order_item->getAdjustments());
+    $this->assertEquals(1, $second_order_item->getQuantity());
+
+    // Increase the quantity of the "buy" product to 6, the quantity of the
+    // offered product will be increased to 2.
+    $first_order_item->setQuantity(6);
+    $this->order->setItems([$first_order_item, $second_order_item]);
+    $this->order->save();
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
+
+    list($first_order_item, $second_order_item) = $this->order->getItems();
+
+    $this->assertEquals(6, $first_order_item->getQuantity());
+    $this->assertCount(0, $first_order_item->getAdjustments());
+    $this->assertEquals(2, $second_order_item->getQuantity());
+    $this->assertCount(1, $second_order_item->getAdjustments());
+    $this->assertAdjustmentPrice($second_order_item->getAdjustments()[0], '-60');
+
+    // Try to remove the "get" product from the order, it will be added back
+    // automatically.
+    $this->order->removeItem($second_order_item);
+    $this->assertCount(1, $this->order->getItems());
+    $this->order->save();
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
+
+    list($first_order_item, $second_order_item) = $this->order->getItems();
+
+    $this->assertEquals(6, $first_order_item->getQuantity());
+    $this->assertEquals(2, $second_order_item->getQuantity());
+    $this->assertCount(1, $second_order_item->getAdjustments());
+    $this->assertAdjustmentPrice($second_order_item->getAdjustments()[0], '-60');
+
+    // Decrease the quantity of the "buy" product from the order, the "get"
+    // quantity will be decreased and the discount will only be applied once.
+    $first_order_item->setQuantity(5);
+    $this->order->setItems([$first_order_item, $second_order_item]);
+    $this->order->save();
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
+
+    list($first_order_item, $second_order_item) = $this->order->getItems();
+
+    $this->assertEquals(5, $first_order_item->getQuantity());
+    $this->assertEquals(1, $second_order_item->getQuantity());
+    $this->assertCount(1, $second_order_item->getAdjustments());
+    $this->assertAdjustmentPrice($second_order_item->getAdjustments()[0], '-30');
+  }
+
+  /**
+   * Tests that the auto-added get order item is automatically removed.
+   *
+   * @covers ::apply
+   * @covers ::clear
+   */
+  public function testAutoRemoveOrderItem() {
+    $offer = $this->promotion->getOffer();
+    $offer_configuration = $offer->getConfiguration();
+    // The customer purchases 3 quantities of any product.
+    $offer_configuration['buy_quantity'] = '1';
+    $offer_configuration['buy_conditions'] = [
+      [
+        'plugin' => 'order_item_purchased_entity:commerce_product_variation',
+        'configuration' => [
+          'entities' => [
+            $this->variations[0]->uuid(),
+          ],
+        ],
+      ],
+    ];
+    // The customer receives 1 specific product for free.
+    $offer_configuration['get_quantity'] = '1';
+    $offer_configuration['get_conditions'] = [
+      [
+        'plugin' => 'order_item_purchased_entity:commerce_product_variation',
+        'configuration' => [
+          'entities' => [
+            $this->variations[1]->uuid(),
+          ],
+        ],
+      ],
+    ];
+    $offer_configuration['get_auto_add'] = TRUE;
+    $offer_configuration['offer_type'] = 'percentage';
+    $offer_configuration['offer_percentage'] = '1';
+    $offer_configuration['offer_amount'] = NULL;
+    $offer->setConfiguration($offer_configuration);
+    $this->promotion->setOffer($offer);
+    $this->promotion->set('conditions', [
+      [
+        'target_plugin_id' => 'order_total_price',
+        'target_plugin_configuration' => [
+          'operator' => '>=',
+          'amount' => [
+            'number' => '15.00',
+            'currency_code' => 'USD',
+          ],
+        ],
+      ],
+    ]);
+    $this->promotion->save();
+    $this->variations[1]->setPrice(new Price('15', 'USD'))->save();
+    /** @var \Drupal\commerce_order\OrderItemStorageInterface $order_item_storage */
+    $order_item_storage = \Drupal::entityTypeManager()->getStorage('commerce_order_item');
+    $first_order_item = $order_item_storage->createFromPurchasableEntity($this->variations[0], [
+      'quantity' => '2',
+    ]);
+    $first_order_item->save();
+    $this->order->setItems([$first_order_item]);
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
+    list($first_order_item, $second_order_item) = $this->order->getItems();
+
+    $this->assertEquals(2, $first_order_item->getQuantity());
+    $this->assertEquals(2, $second_order_item->getQuantity());
+    $this->assertCount(1, $second_order_item->getAdjustments());
+    $this->assertAdjustmentPrice($second_order_item->getAdjustments()[0], '-30');
+
+    $first_order_item->setQuantity('1');
+    $first_order_item->save();
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
+    $this->assertCount(1, $this->order->getItems());
+    $this->assertEquals(new Price('10', 'USD'), $this->order->getTotalPrice());
+
+    // Test that a promotion that is no longer applicable is also cleared out.
+    $first_order_item->setQuantity('2');
+    $first_order_item->save();
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
+    $this->assertCount(2, $this->order->getItems());
+    $this->promotion->setEnabled(FALSE);
+    $this->promotion->save();
+    $this->container->get('commerce_order.order_refresh')->refresh($this->order);
+    $this->assertCount(1, $this->order->getItems());
+  }
+
+  /**
+   * Asserts that a promotion adjustment has the expected price.
+   *
+   * @param \Drupal\commerce_order\Adjustment $adjustment
+   *   The adjustment to test.
+   * @param string $price
+   *   The expected price, as a string.
+   * @param string $currency_code
+   *   The expected currency code.
+   */
+  protected function assertAdjustmentPrice(Adjustment $adjustment, $price, $currency_code = 'USD') {
+    $this->assertEquals('promotion', $adjustment->getType());
+    $this->assertEquals(new Price($price, $currency_code), $adjustment->getAmount());
+    $this->assertEquals($this->promotion->id(), $adjustment->getSourceId());
+  }
+
 }
diff --git a/src/Plugin/Commerce/Condition/PurchasableEntityConditionInterface.php b/src/Plugin/Commerce/Condition/PurchasableEntityConditionInterface.php
new file mode 100644
index 00000000..8bc93127
--- /dev/null
+++ b/src/Plugin/Commerce/Condition/PurchasableEntityConditionInterface.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\commerce\Plugin\Commerce\Condition;
+
+/**
+ * Defines the interface for conditions that deal with purchasable entities.
+ */
+interface PurchasableEntityConditionInterface {
+
+  /**
+   * Gets the configured purchasable entity IDS.
+   *
+   * @return int|string[]
+   *   An array of purchasable entity IDs.
+   */
+  public function getPurchasableEntityIds();
+
+  /**
+   * Gets the configured purchasable entities.
+   *
+   * @return \Drupal\commerce\PurchasableEntityInterface[]
+   *   An array of purchasable entities.
+   */
+  public function getPurchasableEntities();
+
+}
