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/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..e6ae90ba 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 entity.
+   *
+   * @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..0f038e32 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,6 +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);
@@ -188,6 +234,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 +284,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 +322,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 +332,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']);
     }
   }
 
@@ -245,6 +345,28 @@ class BuyXGetY extends OrderPromotionOfferBase {
     $order = $entity;
     $order_items = $order->getItems();
 
+    // Since the adjustments added by this offer plugin are "locked", we need
+    // to take care of cleaning them up ourselves here.
+    foreach ($order_items as $order_item) {
+      $order_item->setAdjustments(array_filter($order_item->getAdjustments(), function (Adjustment $adjustment) use ($promotion) {
+        return $adjustment->getSourceId() !== $promotion->id();
+      }));
+    }
+
+    // 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 below. If the order item will have
+    // no quantity left, it will be removed.
+    if ($this->configuration['get_auto_add']) {
+      $auto_add_order_items = array_filter($order_items, function (OrderItemInterface $order_item) use ($promotion) {
+        return $order_item->getData("promotion:{$promotion->id()}:auto_add_quantity");
+      });
+      foreach ($auto_add_order_items as $order_item) {
+        $new_quantity = Calculator::subtract($order_item->getQuantity(), $order_item->getData("promotion:{$promotion->id()}:auto_add_quantity"));
+        $order_item->setQuantity($new_quantity);
+      }
+    }
+
     $buy_conditions = $this->buildConditionGroup($this->configuration['buy_conditions']);
     $buy_order_items = $this->selectOrderItems($order_items, $buy_conditions, 'DESC');
     $buy_quantities = array_map(function (OrderItemInterface $order_item) {
@@ -255,6 +377,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 +437,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 +449,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 +465,31 @@ 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;
+    $order_items = $order->getItems();
+
+    // Remove any leftover order items that were auto-added.
+    foreach ($order_items as $order_item) {
+      if ($order_item->getData("promotion:{$promotion->id()}:auto_add_quantity")) {
+        $order->removeItem($order_item);
+        $order_item->delete();
+      }
+    }
+  }
+
   /**
    * Builds a condition group for the given condition configuration.
    *
@@ -368,6 +551,142 @@ class BuyXGetY extends OrderPromotionOfferBase {
     return $selected_order_items;
   }
 
+  /**
+   * Find the configured purchasable entity amongst the given conditions.
+   *
+   * @param \Drupal\commerce\ConditionGroup $conditions
+   *   The condition group.
+   *
+   * @return \Drupal\commerce\PurchasableEntityInterface|null
+   *   The purchasable entity, 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.
    *
@@ -383,7 +702,7 @@ class BuyXGetY extends OrderPromotionOfferBase {
    * @return array
    *   The quantity list slice.
    */
-  protected function sliceQuantities(array &$quantities, $total_quantity) {
+  protected function sliceQuantities(array &$quantities, string $total_quantity) {
     $remaining_quantity = $total_quantity;
     $slice = [];
     foreach ($quantities as $order_item_id => $quantity) {
@@ -472,7 +791,7 @@ class BuyXGetY extends OrderPromotionOfferBase {
    * @return \Drupal\commerce_price\Price
    *   The adjustment amount.
    */
-  protected function buildAdjustmentAmount(OrderItemInterface $order_item, $quantity) {
+  protected function buildAdjustmentAmount(OrderItemInterface $order_item, string $quantity) {
     if ($this->configuration['offer_type'] == 'percentage') {
       $percentage = (string) $this->configuration['offer_percentage'];
       $total_price = $order_item->getTotalPrice();
@@ -492,4 +811,16 @@ class BuyXGetY extends OrderPromotionOfferBase {
     return $adjustment_amount;
   }
 
+  /**
+   * Get 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/src/PromotionOrderProcessor.php b/modules/promotion/src/PromotionOrderProcessor.php
index 44bb5c39..4fe679e2 100644
--- a/modules/promotion/src/PromotionOrderProcessor.php
+++ b/modules/promotion/src/PromotionOrderProcessor.php
@@ -14,11 +14,11 @@ use Drupal\Core\Language\LanguageManagerInterface;
 class PromotionOrderProcessor implements OrderProcessorInterface {
 
   /**
-   * The promotion storage.
+   * The entity type manager.
    *
-   * @var \Drupal\commerce_promotion\PromotionStorageInterface
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
    */
-  protected $promotionStorage;
+  protected $entityTypeManager;
 
   /**
    * The language manager.
@@ -36,7 +36,7 @@ class PromotionOrderProcessor implements OrderProcessorInterface {
    *   The language manager.
    */
   public function __construct(EntityTypeManagerInterface $entity_type_manager, LanguageManagerInterface $language_manager) {
-    $this->promotionStorage = $entity_type_manager->getStorage('commerce_promotion');
+    $this->entityTypeManager = $entity_type_manager;
     $this->languageManager = $language_manager;
   }
 
@@ -62,17 +62,36 @@ class PromotionOrderProcessor implements OrderProcessorInterface {
     }
 
     // Non-coupon promotions are loaded and applied separately.
-    $promotions = $this->promotionStorage->loadAvailable($order);
-    foreach ($promotions as $promotion) {
+    /** @var \Drupal\commerce_promotion\PromotionStorageInterface $promotion_storage */
+    $promotion_storage = $this->entityTypeManager->getStorage('commerce_promotion');
+    $promotions = $promotion_storage->loadAvailable($order);
+    $promotion_adjustments = $order->collectAdjustments(['promotion']);
+    $promotions_applied = [];
+    foreach ($promotions as $key => $promotion) {
       if ($promotion->applies($order)) {
         // Ensure the promotion is in the right language, to ensure promotions
         // adjustments labels are correctly translated.
         if ($promotion->hasTranslation($content_langcode)) {
           $promotion = $promotion->getTranslation($content_langcode);
         }
+        $promotions_applied[$key] = $promotion;
         $promotion->apply($order);
       }
     }
+
+    // Check if the order has any promotions that no longer apply, and allow
+    // them to clean up any modifications they might have done on the order.
+    foreach ($promotion_adjustments as $adjustment) {
+      // This promotion still applies, skip to the next adjustment.
+      if (isset($promotions_applied[$adjustment->getSourceId()])) {
+        continue;
+      }
+      // Skip to the next adjustment if the promotion no longer exists.
+      if (!$promotion = $promotion_storage->load($adjustment->getSourceId())) {
+        continue;
+      }
+      $promotion->clear($order);
+    }
   }
 
 }
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..6053311d 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;
@@ -227,6 +228,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,7 +236,6 @@ class BuyXGetYTest extends OrderKernelTestBase {
       'quantity' => '2',
     ]);
     $this->order->addItem($fourth_order_item);
-    $this->order->clearAdjustments();
     $this->order->save();
     $this->promotion->apply($this->order);
     list($first_order_item, $second_order_item, $third_order_item, $fourth_order_item) = $this->order->getItems();
@@ -250,6 +251,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 +259,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());
   }
 
   /**
@@ -304,6 +307,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,7 +315,6 @@ class BuyXGetYTest extends OrderKernelTestBase {
       'quantity' => '3',
     ]);
     $this->order->addItem($fourth_order_item);
-    $this->order->clearAdjustments();
     $this->order->save();
     $this->promotion->apply($this->order);
     list($first_order_item, $second_order_item, $third_order_item, $fourth_order_item) = $this->order->getItems();
@@ -327,6 +330,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 +338,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());
   }
 
   /**
@@ -526,4 +531,208 @@ 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->promotion->apply($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->promotion->apply($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->promotion->apply($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->promotion->apply($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->promotion->apply($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->setEnabled(TRUE);
+    $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->order->set('state', 'draft');
+    $this->order->save();
+    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');
+
+    // Assert that refreshing the order automatically removes the order item.
+    $first_order_item->setQuantity('1');
+    $first_order_item->save();
+    $this->order->recalculateTotalPrice();
+    $this->order->save();
+    $this->assertCount(1, $this->order->getItems());
+    $this->assertEquals(new Price('10', 'USD'), $this->order->getTotalPrice());
+  }
+
+  /**
+   * 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();
+
+}
