From 0df102f924c94981dec905a9bf7baf37218db76c Mon Sep 17 00:00:00 2001
From: mglaman <mglaman@2416470.no-reply.drupal.org>
Date: Wed, 22 Mar 2017 12:33:54 -0500
Subject: [PATCH] Issue #2854313 by niko-, mglaman, joshmiller: Improve and
 expand Promotion plugins

---
 modules/order/src/EntityAdjustableInterface.php    |   4 +-
 .../Commerce/PromotionCondition/ProductEquals.php  | 105 ++++++++++++
 .../PromotionCondition/ProductFieldEqual.php       | 186 ++++++++++++++++++++
 .../ProductVariationFieldEqual.php                 | 188 +++++++++++++++++++++
 .../tests/src/Kernel/PromotionConditionsTest.php   | 176 +++++++++++++++++++
 .../src/Annotation/CommercePromotionOffer.php      |   9 -
 modules/promotion/src/CouponOrderProcessor.php     |  11 +-
 modules/promotion/src/Entity/Promotion.php         |  25 ++-
 .../Plugin/Commerce/PromotionOffer/FixedOff.php    |  88 ++++++++++
 .../Commerce/PromotionOffer/OrderPercentageOff.php |  27 ---
 .../Commerce/PromotionOffer/PercentageOff.php      |  93 ++++++++++
 .../Commerce/PromotionOffer/PercentageOffBase.php  |  71 --------
 .../PromotionOffer/ProductPercentageOff.php        |  27 ---
 .../Commerce/PromotionOffer/PromotionOfferBase.php |   9 +-
 .../PromotionOffer/PromotionOfferInterface.php     |  13 +-
 modules/promotion/src/PromotionOfferManager.php    |  13 +-
 .../tests/src/Functional/CouponRedemptionTest.php  |   3 +-
 .../src/FunctionalJavascript/PromotionTest.php     |  18 +-
 .../src/Kernel/CouponOrderIntegrationTest.php      |   1 +
 .../tests/src/Kernel/CouponStorageTest.php         |   1 +
 .../tests/src/Kernel/CouponValidationTest.php      |   1 +
 .../src/Kernel/CouponsFieldPostUpdateTest.php      |   1 +
 .../tests/src/Kernel/PromotionConditionTest.php    |  15 +-
 .../tests/src/Kernel/PromotionOfferTest.php        |  17 +-
 .../src/Kernel/PromotionOrderProcessorTest.php     |   5 +-
 .../tests/src/Kernel/PromotionStorageTest.php      |   1 +
 src/Element/PluginSelect.php                       |   4 +
 27 files changed, 899 insertions(+), 213 deletions(-)
 create mode 100644 modules/product/src/Plugin/Commerce/PromotionCondition/ProductEquals.php
 create mode 100644 modules/product/src/Plugin/Commerce/PromotionCondition/ProductFieldEqual.php
 create mode 100644 modules/product/src/Plugin/Commerce/PromotionCondition/ProductVariationFieldEqual.php
 create mode 100644 modules/product/tests/src/Kernel/PromotionConditionsTest.php
 create mode 100644 modules/promotion/src/Plugin/Commerce/PromotionOffer/FixedOff.php
 delete mode 100644 modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderPercentageOff.php
 create mode 100644 modules/promotion/src/Plugin/Commerce/PromotionOffer/PercentageOff.php
 delete mode 100644 modules/promotion/src/Plugin/Commerce/PromotionOffer/PercentageOffBase.php
 delete mode 100644 modules/promotion/src/Plugin/Commerce/PromotionOffer/ProductPercentageOff.php

diff --git a/modules/order/src/EntityAdjustableInterface.php b/modules/order/src/EntityAdjustableInterface.php
index c410412..ae5b19d 100644
--- a/modules/order/src/EntityAdjustableInterface.php
+++ b/modules/order/src/EntityAdjustableInterface.php
@@ -7,8 +7,8 @@
 /**
  * Defines an interface for objects that contain adjustments.
  *
- * @see \Drupal\commerce_order\Entity\OrderInterfaceEntity
- * @see \Drupal\commerce_order\Entity\OrderItemInterfaceEntity
+ * @see \Drupal\commerce_order\Entity\OrderInterface
+ * @see \Drupal\commerce_order\Entity\OrderItemInterface
  */
 interface EntityAdjustableInterface extends EntityInterface {
 
diff --git a/modules/product/src/Plugin/Commerce/PromotionCondition/ProductEquals.php b/modules/product/src/Plugin/Commerce/PromotionCondition/ProductEquals.php
new file mode 100644
index 0000000..15a9a46
--- /dev/null
+++ b/modules/product/src/Plugin/Commerce/PromotionCondition/ProductEquals.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Drupal\commerce_product\Plugin\Commerce\PromotionCondition;
+
+use Drupal\commerce_promotion\Plugin\Commerce\PromotionCondition\PromotionConditionBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides an 'Order item: product equals' condition.
+ *
+ * @CommercePromotionCondition(
+ *   id = "commerce_promotion_product_equals",
+ *   label = @Translation("Product equals"),
+ *   target_entity_type = "commerce_order_item",
+ * )
+ */
+class ProductEquals extends PromotionConditionBase implements ContainerFactoryPluginInterface {
+
+  protected $productStorage;
+
+  /**
+   * Constructs a new ProductEquals object.
+   *
+   * @param array $configuration
+   *   The plugin configuration, i.e. an array with configuration values keyed
+   *   by configuration option name. The special key 'context' may be used to
+   *   initialize the defined contexts by setting it to an array of context
+   *   values keyed by context names.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->productStorage = $entity_type_manager->getStorage('commerce_product');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return [
+      'product_id' => NULL,
+    ] + parent::defaultConfiguration();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form = parent::buildConfigurationForm($form, $form_state);
+
+    $product = $this->productStorage->load($this->configuration['product_id']);
+    $form['product_id'] = [
+      '#type' => 'entity_autocomplete',
+      '#title' => $this->t('Product'),
+      '#default_value' => $product,
+      '#target_type' => 'commerce_product',
+    ];
+
+    // @todo allow drill down to limit to specific variants.
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function evaluate() {
+    $product_id = $this->configuration['product_id'];
+    if (empty($product_id)) {
+      return FALSE;
+    }
+
+    /** @var \Drupal\commerce_product\Entity\ProductInterface $current_product */
+    $current_product = $this->getTargetEntity()->getPurchasedEntity()->getProduct();
+
+    return $current_product->id() == $product_id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function summary() {
+    return $this->t('Compares the purchased product.');
+  }
+
+}
diff --git a/modules/product/src/Plugin/Commerce/PromotionCondition/ProductFieldEqual.php b/modules/product/src/Plugin/Commerce/PromotionCondition/ProductFieldEqual.php
new file mode 100644
index 0000000..b60e7d8
--- /dev/null
+++ b/modules/product/src/Plugin/Commerce/PromotionCondition/ProductFieldEqual.php
@@ -0,0 +1,186 @@
+<?php
+
+namespace Drupal\commerce_product\Plugin\Commerce\PromotionCondition;
+
+use Drupal\commerce_promotion\Plugin\Commerce\PromotionCondition\PromotionConditionBase;
+use Drupal\commerce_order\Entity\OrderItemInterface;
+use Drupal\Component\Utility\Html;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\taxonomy\TermInterface;
+use Drupal\taxonomy\TermStorage;
+
+/**
+ * Provides an 'Order: Total amount comparison' condition.
+ *
+ * @CommercePromotionCondition(
+ *   id = "commerce_promotion_product_field_equal",
+ *   label = @Translation("Product field is"),
+ *   target_entity_type = "commerce_order_item",
+ * )
+ */
+class ProductFieldEqual extends PromotionConditionBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return [
+        'bundle' => NULL,
+        'field' => NULL,
+      ] + parent::defaultConfiguration();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form += parent::buildConfigurationForm($form, $form_state);
+    $ajax_wrapper_id = Html::getUniqueId('ajax-wrapper');
+    // Prefix and suffix used for Ajax replacement.
+    $form['#prefix'] = '<div id="' . $ajax_wrapper_id . '">';
+    $form['#suffix'] = '</div>';
+
+    $selected_bundle = isset($this->configuration['bundle']) ? $this->configuration['bundle'] : NULL;
+    $bundles = \Drupal::service("entity_type.bundle.info")->getBundleInfo('commerce_product');
+    $bundle_options = [];
+    foreach ($bundles as $bundle => $label) {
+      $bundle_options[$bundle] = $label['label'];
+    }
+
+    $form['bundle'] = [
+      '#type' => 'select',
+      '#options' => $bundle_options,
+      '#title' => t('Product bundle'),
+      '#default_value' =>  $selected_bundle,
+      '#required' => TRUE,
+      '#ajax' => [
+        'callback' => [$this, 'bundleAjaxCallback'],
+        'wrapper' => $ajax_wrapper_id,
+      ],
+    ];
+    if (!$selected_bundle) {
+      return $form;
+    }
+
+    $fields = \Drupal::service("entity_field.manager")->getFieldDefinitions('commerce_product', $selected_bundle);
+    $selected_field = isset($this->configuration['field']) ? $this->configuration['field'] : NULL;
+
+    $filed_options = [];
+    foreach ($fields as $field_id => $field_definition) {
+      $filed_options[$field_id] = $field_definition->getLabel();
+    }
+
+    $form['field'] = [
+      '#type' => 'select',
+      '#title' => t('Field'),
+      '#options' => $filed_options,
+      '#default_value' =>  $selected_field,
+      '#required' => TRUE,
+      '#ajax' => [
+        'callback' => [$this, 'bundleAjaxCallback'],
+        'wrapper' => $ajax_wrapper_id,
+      ],
+    ];
+
+    if (!$selected_field) {
+      return $form;
+    }
+
+    //Create an empty representative entity
+    $commerce_product = \Drupal::service('entity_type.manager')->getStorage('commerce_product')->create(array(
+        'type' => $selected_bundle,
+        $selected_field => $this->configuration[$selected_field],
+      )
+    );
+
+    //Get the EntityFormDisplay (i.e. the default Form Display) of this content type
+    $entity_form_display = \Drupal::service('entity_type.manager')->getStorage('entity_form_display')
+      ->load('commerce_product.' . $selected_bundle . '.default');
+
+    //Get the body field widget and add it to the form
+    if ($widget = $entity_form_display->getRenderer($selected_field)) { //Returns the widget class
+      $items = $commerce_product->get($selected_field); //Returns the FieldItemsList interface
+      $items->filterEmptyItems();
+      $form[$selected_field] = $widget->form($items, $form, $form_state); //Builds the widget form and attach it to your form
+      $form[$selected_field]['widget']['#required'] = TRUE;
+    }
+
+    return $form;
+  }
+
+
+  public function bundleAjaxCallback(array $form, FormStateInterface $form_state) {
+    $triggering_element = $form_state->getTriggeringElement();
+    $parents = array_slice($triggering_element['#array_parents'], 0, -1);
+    $form_element = NestedArray::getValue($form, $parents);
+    return $form_element;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function evaluate() {
+    $bundle_id = $this->configuration['bundle'];
+    if (empty($bundle_id)) {
+      return FALSE;
+    }
+    $field_id = $this->configuration['field'];
+    if (empty($field_id)) {
+      return FALSE;
+    }
+    /** @var OrderItemInterface $order_item */
+    $order_item = $this->getContextValue('commerce_order_item');
+
+    /** @var \Drupal\commerce_product\Entity\ProductInterface $current_product */
+    $current_product = $order_item->getPurchasedEntity()->getProduct();
+    if ($current_product->bundle() != $bundle_id) {
+      return FALSE;
+    }
+
+    if (!$current_product->hasField($field_id)) {
+      return FALSE;
+    }
+
+    $field_type = $current_product->get($field_id)->getFieldDefinition()->getType();
+    $target_type = NULL;
+    if ($field_type == 'entity_reference') {
+      $target_type = $current_product->get($field_id)->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type');
+    }
+
+    if ($target_type == 'taxonomy_term') {
+      if ($current_product->get($field_id)->getValue() == $this->configuration[$field_id]) {
+        return TRUE;
+      }
+      else {
+        /** @var TermInterface $term */
+        $term = \Drupal::service('entity_type.manager')
+          ->getStorage("taxonomy_term")->load($this->configuration[$field_id][0]['target_id']);
+        $tree = \Drupal::service('entity_type.manager')
+          ->getStorage("taxonomy_term")
+          ->loadTree($term->getVocabularyId(), $term->id());
+        $found = FALSE;
+        foreach ($tree as $item) {
+          if ($item->tid == $current_product->get($field_id)->getValue()[0]['target_id']) {
+            $found = TRUE;
+            break;
+          }
+        }
+        return $found;
+      }
+    }
+    elseif ($current_product->get($field_id)->getValue() != $this->configuration[$field_id]) {
+      return FALSE;
+    }
+
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function summary() {
+    return $this->t('Compares the product entity.');
+  }
+
+}
diff --git a/modules/product/src/Plugin/Commerce/PromotionCondition/ProductVariationFieldEqual.php b/modules/product/src/Plugin/Commerce/PromotionCondition/ProductVariationFieldEqual.php
new file mode 100644
index 0000000..cfc6927
--- /dev/null
+++ b/modules/product/src/Plugin/Commerce/PromotionCondition/ProductVariationFieldEqual.php
@@ -0,0 +1,188 @@
+<?php
+
+namespace Drupal\commerce_product\Plugin\Commerce\PromotionCondition;
+
+use Drupal\commerce_promotion\Plugin\Commerce\PromotionCondition\PromotionConditionBase;
+use Drupal\commerce_product\Entity\ProductVariationInterface;
+use Drupal\Component\Utility\Html;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\taxonomy\TermInterface;
+
+/**
+ * Provides an 'Order: Total amount comparison' condition.
+ *
+ * @CommercePromotionCondition(
+ *   id = "commerce_promotion_product_variation_field_equal",
+ *   label = @Translation("Product Variation field is"),
+ *   target_entity_type = "commerce_order_item",
+ * )
+ */
+class ProductVariationFieldEqual extends PromotionConditionBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return [
+      'bundle' => NULL,
+      'field' => NULL,
+      'value' => NULL,
+    ] + parent::defaultConfiguration();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form += parent::buildConfigurationForm($form, $form_state);
+    $ajax_wrapper_id = Html::getUniqueId('ajax-wrapper');
+    // Prefix and suffix used for Ajax replacement.
+    $form['#prefix'] = '<div id="' . $ajax_wrapper_id . '">';
+    $form['#suffix'] = '</div>';
+
+//    $entity_id = isset($this->configuration['entity']) ? $this->configuration['entity'] : NULL;
+    $selected_bundle = isset($this->configuration['bundle']) ? $this->configuration['bundle'] : NULL;
+    $bundles = \Drupal::service("entity_type.bundle.info")->getBundleInfo('commerce_product_variation');
+    $bundle_options = [];
+    foreach ($bundles as $bundle => $label) {
+      $bundle_options[$bundle] = $label['label'];
+    }
+
+    $form['bundle'] = [
+      '#type' => 'select',
+      '#options' => $bundle_options,
+      '#title' => t('Product variation bundle'),
+      '#default_value' =>  $selected_bundle,
+      '#required' => TRUE,
+      '#ajax' => [
+        'callback' => [$this, 'bundleAjaxCallback'],
+        'wrapper' => $ajax_wrapper_id,
+      ],
+    ];
+    if (!$selected_bundle) {
+      return $form;
+    }
+
+    $fields = \Drupal::service("entity_field.manager")->getFieldDefinitions('commerce_product_variation', $selected_bundle);
+    $selected_field = isset($this->configuration['field']) ? $this->configuration['field'] : NULL;
+
+    $filed_options = [];
+    foreach ($fields as $field_id => $field_definition) {
+      $filed_options[$field_id] = $field_definition->getLabel();
+    }
+
+    $form['field'] = [
+      '#type' => 'select',
+      '#title' => t('Field'),
+      '#options' => $filed_options,
+      '#default_value' =>  $selected_field,
+      '#required' => TRUE,
+      '#ajax' => [
+        'callback' => [$this, 'bundleAjaxCallback'],
+        'wrapper' => $ajax_wrapper_id,
+      ],
+    ];
+
+    if (!$selected_field) {
+      return $form;
+    }
+
+    //Create an empty representative entity
+    $commerce_product_variation = \Drupal::service('entity_type.manager')->getStorage('commerce_product_variation')->create(array(
+        'type' => $selected_bundle,
+        $selected_field => $this->configuration[$selected_field],
+      )
+    );
+
+    //Get the EntityFormDisplay (i.e. the default Form Display) of this content type
+    $entity_form_display = \Drupal::service('entity_type.manager')->getStorage('entity_form_display')
+      ->load('commerce_product_variation.' . $selected_bundle . '.default');
+
+    //Get the body field widget and add it to the form
+    if ($widget = $entity_form_display->getRenderer($selected_field)) { //Returns the widget class
+      $items = $commerce_product_variation->get($selected_field); //Returns the FieldItemsList interface
+      $items->filterEmptyItems();
+      $form[$selected_field] = $widget->form($items, $form, $form_state); //Builds the widget form and attach it to your form
+      $form[$selected_field]['widget']['#required'] = TRUE;
+    }
+
+    return $form;
+  }
+
+  public function bundleAjaxCallback(array $form, FormStateInterface $form_state) {
+    $triggering_element = $form_state->getTriggeringElement();
+    $parents = array_slice($triggering_element['#array_parents'], 0, -1);
+    $form_element = NestedArray::getValue($form, $parents);
+    return $form_element;
+  }
+
+
+  /**
+   * {@inheritdoc}
+   */
+  public function evaluate() {
+    $bundle_id = $this->configuration['bundle'];
+    if (empty($bundle_id)) {
+      return FALSE;
+    }
+    $field_id = $this->configuration['field'];
+    if (empty($field_id)) {
+      return FALSE;
+    }
+    /** @var OrderItemInterface $order_item */
+    $order_item = $this->getContextValue('commerce_order_item');
+
+    /** @var ProductVariationInterface $current_product */
+    $current_product_variation = $order_item->getPurchasedEntity();
+    if ($current_product_variation->bundle() != $bundle_id) {
+      return FALSE;
+    }
+
+    if (!$current_product_variation->hasField($field_id)) {
+      return FALSE;
+    }
+
+    $field_type = $current_product_variation->get($field_id)->getFieldDefinition()->getType();
+    $target_type = NULL;
+    if ($field_type == 'entity_reference') {
+      $target_type = $current_product->get($field_id)->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type');
+    }
+
+    if ($target_type == 'taxonomy_term') {
+      if ($current_product_variation->get($field_id)->getValue() == $this->configuration[$field_id]) {
+        return TRUE;
+      }
+      else {
+        /** @var TermInterface $term */
+        $term = \Drupal::service('entity_type.manager')
+          ->getStorage("taxonomy_term")->load($this->configuration[$field_id][0]['target_id']);
+        $tree = \Drupal::service('entity_type.manager')
+          ->getStorage("taxonomy_term")
+          ->loadTree($term->getVocabularyId(), $term->id());
+        $found = FALSE;
+        foreach ($tree as $item) {
+          if ($item->tid == $current_product_variation->get($field_id)->getValue()[0]['target_id']) {
+            $found = TRUE;
+            break;
+          }
+        }
+        return $found;
+      }
+    }
+    elseif ($current_product_variation->get($field_id)->getValue() != $this->configuration[$field_id]) {
+      return FALSE;
+    }
+
+    return TRUE;
+
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function summary() {
+    return $this->t('Compares the product variation entity.');
+  }
+
+}
diff --git a/modules/product/tests/src/Kernel/PromotionConditionsTest.php b/modules/product/tests/src/Kernel/PromotionConditionsTest.php
new file mode 100644
index 0000000..0e95f79
--- /dev/null
+++ b/modules/product/tests/src/Kernel/PromotionConditionsTest.php
@@ -0,0 +1,176 @@
+<?php
+
+namespace Drupal\Tests\commerce_product\Kernel;
+
+use Drupal\commerce_order\Entity\Order;
+use Drupal\commerce_order\Entity\OrderItem;
+use Drupal\commerce_product\Entity\Product;
+use Drupal\commerce_product\Entity\ProductVariation;
+use Drupal\commerce_promotion\Entity\Promotion;
+use Drupal\Tests\commerce\Kernel\CommerceKernelTestBase;
+
+/**
+ * Tests the promotion conditions provided by commerce_product
+ *
+ * @group commerce
+ * @group commerce_product
+ * @group commerce_promotion
+ */
+class PromotionConditionsTest extends CommerceKernelTestBase {
+
+  /**
+   * The condition manager.
+   *
+   * @var \Drupal\commerce_promotion\PromotionConditionManager
+   */
+  protected $conditionManager;
+
+  /**
+   * The test order.
+   *
+   * @var \Drupal\commerce_order\Entity\OrderInterface
+   */
+  protected $order;
+
+  /**
+   * The variation to test against.
+   *
+   * @var \Drupal\commerce_product\Entity\ProductVariation
+   */
+  protected $variation;
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = [
+    'path',
+    'entity_reference_revisions',
+    'profile',
+    'state_machine',
+    'commerce_order',
+    'commerce_product',
+    'commerce_promotion',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installEntitySchema('commerce_product_variation');
+    $this->installEntitySchema('commerce_product_variation_type');
+    $this->installEntitySchema('commerce_product');
+    $this->installEntitySchema('commerce_product_type');
+    $this->installEntitySchema('profile');
+    $this->installEntitySchema('commerce_order');
+    $this->installEntitySchema('commerce_order_type');
+    $this->installEntitySchema('commerce_order_item');
+    $this->installEntitySchema('commerce_promotion');
+    $this->installConfig([
+      'commerce_product',
+      'profile',
+      'commerce_order',
+      'commerce_promotion',
+    ]);
+
+    $this->order = Order::create([
+      'type' => 'default',
+      'state' => 'completed',
+      'mail' => 'test@example.com',
+      'ip_address' => '127.0.0.1',
+      'order_number' => '6',
+      'store_id' => $this->store,
+      'order_items' => [],
+    ]);
+
+    $this->variation = ProductVariation::create([
+      'type' => 'default',
+      'sku' => $this->randomMachineName(),
+      'price' => [
+        'number' => '9.99',
+        'currency_code' => 'USD',
+      ],
+    ]);
+    $product = Product::create([
+      'type' => 'default',
+      'title' => $this->randomMachineName(),
+      'stores' => [$this->store],
+      'variations' => [$this->variation],
+    ]);
+    $product->save();
+    $this->reloadEntity($this->variation);
+    $this->variation->product_id = $product->id();
+    $this->variation->save();
+  }
+
+  public function testProductEquals() {
+    $order_item = OrderItem::create([
+      'type' => 'default',
+      'quantity' => 1,
+      'purchased_entity' => $this->variation->id(),
+      'unit_price' => $this->variation->getPrice(),
+    ]);
+    $order_item->save();
+    $this->order->addItem($order_item);
+
+    $promotion = Promotion::create([
+      'name' => 'Promotion 1',
+      'order_types' => [$this->order->bundle()],
+      'stores' => [$this->store->id()],
+      'status' => TRUE,
+      'offer' => [
+        'target_plugin_id' => 'commerce_promotion_order_item_percentage_off',
+        'target_plugin_configuration' => [
+          'amount' => '0.10',
+        ],
+      ],
+      'conditions' => [
+        [
+          'target_plugin_id' => 'commerce_promotion_product_equals',
+          'target_plugin_configuration' => [
+            'product_id' => $this->variation->getProductId(),
+          ],
+        ],
+      ],
+    ]);
+    $promotion->save();
+
+    $this->assertTrue($promotion->applies($order_item));
+
+    $new_product = Product::create([
+      'type' => 'default',
+      'title' => $this->randomMachineName(),
+      'stores' => [$this->store],
+      'variations' => [],
+    ]);
+    $new_product->save();
+
+    $promotion2 = Promotion::create([
+      'name' => 'Promotion 1',
+      'order_types' => [$this->order->bundle()],
+      'stores' => [$this->store->id()],
+      'status' => TRUE,
+      'offer' => [
+        'target_plugin_id' => 'commerce_promotion_order_item_percentage_off',
+        'target_plugin_configuration' => [
+          'amount' => '0.10',
+        ],
+      ],
+      'conditions' => [
+        [
+          'target_plugin_id' => 'commerce_promotion_product_equals',
+          'target_plugin_configuration' => [
+            'product_id' => $new_product->id(),
+          ],
+        ],
+      ],
+    ]);
+    $promotion2->save();
+
+    $this->assertFalse($promotion2->applies($order_item));
+  }
+
+}
diff --git a/modules/promotion/src/Annotation/CommercePromotionOffer.php b/modules/promotion/src/Annotation/CommercePromotionOffer.php
index 19695f7..136a095 100644
--- a/modules/promotion/src/Annotation/CommercePromotionOffer.php
+++ b/modules/promotion/src/Annotation/CommercePromotionOffer.php
@@ -39,15 +39,6 @@ class CommercePromotionOffer extends Plugin {
   public $context = [];
 
   /**
-   * The target entity type this action applies to.
-   *
-   * For example, this should be 'commerce_order' or 'commerce_order_item'.
-   *
-   * @var string
-   */
-  public $target_entity_type;
-
-  /**
    * The category under which the offer should listed in the UI.
    *
    * @var \Drupal\Core\Annotation\Translation
diff --git a/modules/promotion/src/CouponOrderProcessor.php b/modules/promotion/src/CouponOrderProcessor.php
index dca7f3b..ab42d64 100644
--- a/modules/promotion/src/CouponOrderProcessor.php
+++ b/modules/promotion/src/CouponOrderProcessor.php
@@ -69,15 +69,10 @@ public function process(OrderInterface $order) {
         continue;
       }
 
-      /** @var \Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer\PromotionOfferInterface $plugin */
-      $plugin = $promotion->get('offer')->first()->getTargetInstance();
-      $target_entity_type = $plugin->getTargetEntityType();
-      if ($target_entity_type == PromotionOfferInterface::ORDER) {
-        if ($promotion->applies($order)) {
-          $promotion->apply($order);
-        }
+      if ($promotion->applies($order)) {
+        $promotion->apply($order);
       }
-      elseif ($target_entity_type == PromotionOfferInterface::ORDER_ITEM) {
+      else {
         foreach ($order->getItems() as $order_item) {
           if ($promotion->applies($order_item)) {
             $promotion->apply($order_item);
diff --git a/modules/promotion/src/Entity/Promotion.php b/modules/promotion/src/Entity/Promotion.php
index 95932bb..8aa3b2e 100644
--- a/modules/promotion/src/Entity/Promotion.php
+++ b/modules/promotion/src/Entity/Promotion.php
@@ -320,13 +320,6 @@ public function setEnabled($enabled) {
    */
   public function applies(EntityInterface $entity) {
     $entity_type_id = $entity->getEntityTypeId();
-
-    /** @var \Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer\PromotionOfferInterface $offer */
-    $offer = $this->get('offer')->first()->getTargetInstance();
-    if ($offer->getTargetEntityType() !== $entity_type_id) {
-      return FALSE;
-    }
-
     // @todo should whatever invokes this method be providing the context?
     $context = new Context(new ContextDefinition('entity:' . $entity_type_id), $entity);
 
@@ -334,9 +327,16 @@ public function applies(EntityInterface $entity) {
     // @todo support OR operations.
     /** @var \Drupal\commerce\Plugin\Field\FieldType\PluginItem $item */
     foreach ($this->get('conditions') as $item) {
-      /** @var \Drupal\commerce_promotion\Plugin\Commerce\PromotionCondition\PromotionConditionInterface $condition */
-      $condition = $item->getTargetInstance([$entity_type_id => $context]);
-      if (!$condition->evaluate()) {
+      try {
+        /** @var \Drupal\commerce_promotion\Plugin\Commerce\PromotionCondition\PromotionConditionInterface $condition */
+        $condition = $item->getTargetInstance([$entity_type_id => $context]);
+        if (!$condition->evaluate()) {
+          return FALSE;
+        }
+      }
+      catch (\Exception $e) {
+        // In case of exception return false rather than error.
+        // @todo currently this happens with ContextException.
         return FALSE;
       }
     }
@@ -348,12 +348,11 @@ public function applies(EntityInterface $entity) {
    * {@inheritdoc}
    */
   public function apply(EntityInterface $entity) {
-    $entity_type_id = $entity->getEntityTypeId();
     // @todo should whatever invokes this method be providing the context?
-    $context = new Context(new ContextDefinition('entity:' . $entity_type_id), $entity);
+    $context = new Context(new ContextDefinition('entity:' . $entity->getEntityTypeId()), $entity);
 
     /** @var \Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer\PromotionOfferInterface $offer */
-    $offer = $this->get('offer')->first()->getTargetInstance([$entity_type_id => $context]);
+    $offer = $this->get('offer')->first()->getTargetInstance(['adjustable_entity' => $context]);
     $offer->execute();
   }
 
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/FixedOff.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/FixedOff.php
new file mode 100644
index 0000000..736a64e
--- /dev/null
+++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/FixedOff.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer;
+
+use Drupal\commerce_price\Price;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Provides a 'Fixed off' offer.
+ *
+ * @CommercePromotionOffer(
+ *   id = "commerce_promotion_fixed_off",
+ *   label = @Translation("Fixed off"),
+ * )
+ */
+class FixedOff extends PromotionOfferBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return [
+      'amount' => 0,
+    ] + parent::defaultConfiguration();
+  }
+
+  /**
+   * Gets the percentage amount, as a decimal, negated.
+   *
+   * @return string
+   *   The amount.
+   */
+  public function getAmount() {
+    return (string) $this->configuration['amount'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form += parent::buildConfigurationForm($form, $form_state);
+
+    $form['amount'] = [
+      '#type' => 'commerce_number',
+      '#title' => $this->t('Amount'),
+      '#default_value' => $this->configuration['amount'],
+      '#maxlength' => 255,
+      '#required' => TRUE,
+      '#min' => 0,
+      '#size' => 8,
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+    $values = $form_state->getValue($form['#parents']);
+    if (empty($values['target_plugin_configuration']['amount'])) {
+      $form_state->setError($form, $this->t('Fixed amount cannot be empty.'));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+    $values = $form_state->getValue($form['#parents']);
+    $this->configuration['amount'] = $values['amount'];
+    parent::submitConfigurationForm($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function execute() {
+    /** @var \Drupal\commerce_order\EntityAdjustableInterface $entity */
+    $entity = $this->getTargetEntity();
+
+    // @todo is there a sane way to add a getCurrencyCode to EntityAdjustableInterface.
+    // both order item and orders have ::getTotalPrice. Bug in the trenches.
+    $currency_code = $entity->getTotalPrice()->getCurrencyCode();
+    $adjustment_amount = new Price($this->getAmount(), $currency_code);
+    $this->applyAdjustment($entity, $adjustment_amount);
+  }
+}
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderPercentageOff.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderPercentageOff.php
deleted file mode 100644
index 9b44836..0000000
--- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/OrderPercentageOff.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer;
-
-/**
- * Provides a 'Order: Percentage off' condition.
- *
- * @CommercePromotionOffer(
- *   id = "commerce_promotion_order_percentage_off",
- *   label = @Translation("Percentage off"),
- *   target_entity_type = "commerce_order",
- * )
- */
-class OrderPercentageOff extends PercentageOffBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function execute() {
-    /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
-    $order = $this->getTargetEntity();
-    $adjustment_amount = $order->getTotalPrice()->multiply($this->getAmount());
-    $adjustment_amount = $this->rounder->round($adjustment_amount);
-    $this->applyAdjustment($order, $adjustment_amount);
-  }
-
-}
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PercentageOff.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PercentageOff.php
new file mode 100644
index 0000000..5e660b5
--- /dev/null
+++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PercentageOff.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer;
+
+use Drupal\commerce_order\Entity\OrderItemInterface;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Provides a 'Percentage off' condition.
+ *
+ * @CommercePromotionOffer(
+ *   id = "commerce_promotion_percentage_off",
+ *   label = @Translation("Percentage off"),
+ * )
+ */
+class PercentageOff extends PromotionOfferBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return [
+      'amount' => 0,
+    ] + parent::defaultConfiguration();
+  }
+
+  /**
+   * Gets the percentage amount, as a decimal, negated.
+   *
+   * @return string
+   *   The amount.
+   */
+  public function getAmount() {
+    return (string) $this->configuration['amount'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form = parent::buildConfigurationForm($form, $form_state);
+
+    $form['amount'] = [
+      '#type' => 'commerce_number',
+      '#title' => $this->t('Percentage'),
+      '#default_value' => $this->configuration['amount'] * 100,
+      '#maxlength' => 255,
+      '#required' => TRUE,
+      '#min' => 0,
+      '#max' => 100,
+      '#size' => 4,
+      '#field_suffix' => $this->t('%'),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+    $values = $form_state->getValue($form['#parents']);
+    if (empty($values['amount'])) {
+      $form_state->setError($form, $this->t('Percentage amount cannot be empty.'));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+    $values = $form_state->getValue($form['#parents']);
+    $this->configuration['amount'] = (string) ($values['amount'] / 100);
+    parent::submitConfigurationForm($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function execute() {
+    $entity = $this->getTargetEntity();
+
+    if ($entity instanceof OrderItemInterface) {
+      $adjustment_amount = $entity->getUnitPrice()->multiply($this->getAmount());
+    }
+    else {
+      $adjustment_amount = $entity->getTotalPrice()->multiply($this->getAmount());
+    }
+    $adjustment_amount = $this->rounder->round($adjustment_amount);
+    $this->applyAdjustment($entity, $adjustment_amount);
+  }
+
+}
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PercentageOffBase.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PercentageOffBase.php
deleted file mode 100644
index 2ae1a21..0000000
--- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PercentageOffBase.php
+++ /dev/null
@@ -1,71 +0,0 @@
-<?php
-
-namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer;
-
-use Drupal\Core\Form\FormStateInterface;
-
-/**
- * Provides the base class for percentage off offers.
- */
-abstract class PercentageOffBase extends PromotionOfferBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function defaultConfiguration() {
-    return [
-      'amount' => 0,
-    ] + parent::defaultConfiguration();
-  }
-
-  /**
-   * Gets the percentage amount, as a decimal, negated.
-   *
-   * @return string
-   *   The amount.
-   */
-  public function getAmount() {
-    return (string) $this->configuration['amount'];
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
-    $form += parent::buildConfigurationForm($form, $form_state);
-
-    $form['amount'] = [
-      '#type' => 'commerce_number',
-      '#title' => $this->t('Percentage'),
-      '#default_value' => $this->configuration['amount'] * 100,
-      '#maxlength' => 255,
-      '#required' => TRUE,
-      '#min' => 0,
-      '#max' => 100,
-      '#size' => 4,
-      '#field_suffix' => t('%'),
-    ];
-
-    return $form;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
-    $values = $form_state->getValue($form['#parents']);
-    if (empty($values['amount'])) {
-      $form_state->setError($form, $this->t('Percentage amount cannot be empty.'));
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
-    $values = $form_state->getValue($form['#parents']);
-    $this->configuration['amount'] = (string) ($values['amount'] / 100);
-    parent::submitConfigurationForm($form, $form_state);
-  }
-
-}
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/ProductPercentageOff.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/ProductPercentageOff.php
deleted file mode 100644
index cffe155..0000000
--- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/ProductPercentageOff.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-namespace Drupal\commerce_promotion\Plugin\Commerce\PromotionOffer;
-
-/**
- * Provides a 'Product: Percentage off' condition.
- *
- * @CommercePromotionOffer(
- *   id = "commerce_promotion_product_percentage_off",
- *   label = @Translation("Percentage off"),
- *   target_entity_type = "commerce_order_item",
- * )
- */
-class ProductPercentageOff extends PercentageOffBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function execute() {
-    /** @var \Drupal\commerce_order\Entity\OrderItemInterface $order_item */
-    $order_item = $this->getTargetEntity();
-    $adjustment_amount = $order_item->getUnitPrice()->multiply($this->getAmount());
-    $adjustment_amount = $this->rounder->round($adjustment_amount);
-    $this->applyAdjustment($order_item, $adjustment_amount);
-  }
-
-}
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php
index 1d866e3..60884b1 100644
--- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php
+++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferBase.php
@@ -59,15 +59,8 @@ public static function create(ContainerInterface $container, array $configuratio
   /**
    * {@inheritdoc}
    */
-  public function getTargetEntityType() {
-    return $this->pluginDefinition['target_entity_type'];
-  }
-
-  /**
-   * {@inheritdoc}
-   */
   public function getTargetEntity() {
-    return $this->getContextValue($this->getTargetEntityType());
+    return $this->getContextValue('adjustable_entity');
   }
 
   /**
diff --git a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php
index a2c14b8..a675e9f 100644
--- a/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php
+++ b/modules/promotion/src/Plugin/Commerce/PromotionOffer/PromotionOfferInterface.php
@@ -15,21 +15,10 @@
  */
 interface PromotionOfferInterface extends ConfigurablePluginInterface, ContainerFactoryPluginInterface, ContextAwarePluginInterface, ExecutableInterface, PluginFormInterface {
 
-  const ORDER = 'commerce_order';
-  const ORDER_ITEM = 'commerce_order_item';
-
-  /**
-   * Gets the entity type the offer is for.
-   *
-   * @return string
-   *   The entity type it applies to.
-   */
-  public function getTargetEntityType();
-
   /**
    * Get the target entity for the offer.
    *
-   * @return \Drupal\Core\Entity\EntityInterface
+   * @return \Drupal\commerce_order\EntityAdjustableInterface
    *   The target entity.
    */
   public function getTargetEntity();
diff --git a/modules/promotion/src/PromotionOfferManager.php b/modules/promotion/src/PromotionOfferManager.php
index c4660ba..eae99c5 100644
--- a/modules/promotion/src/PromotionOfferManager.php
+++ b/modules/promotion/src/PromotionOfferManager.php
@@ -68,25 +68,20 @@ public function createInstance($plugin_id, array $configuration = []) {
   public function processDefinition(&$definition, $plugin_id) {
     parent::processDefinition($definition, $plugin_id);
 
-    foreach (['id', 'label', 'target_entity_type'] as $required_property) {
+    foreach (['id', 'label'] as $required_property) {
       if (empty($definition[$required_property])) {
         throw new PluginException(sprintf('The promotion offer %s must define the %s property.', $plugin_id, $required_property));
       }
     }
 
-    $target = $definition['target_entity_type'];
-    if (!$this->entityTypeManager->getDefinition($target)) {
-      throw new PluginException(sprintf('The promotion offer %s must reference a valid entity type, %s given.', $plugin_id, $target));
-    }
-
     // If the plugin did not specify a category, use the target entity's label.
     if (empty($definition['category'])) {
-      $definition['category'] = $this->entityTypeManager->getDefinition($target)->getLabel();
+      $definition['category'] = $this->t('Offer');
     }
 
     // Generate the context definition if it is missing.
-    if (empty($definition['context'][$target])) {
-      $definition['context'][$target] = new ContextDefinition('entity:' . $target, $definition['category']);
+    if (empty($definition['context']['adjustable_entity'])) {
+      $definition['context']['adjustable_entity'] = new ContextDefinition('any', $definition['category']);
     }
   }
 
diff --git a/modules/promotion/tests/src/Functional/CouponRedemptionTest.php b/modules/promotion/tests/src/Functional/CouponRedemptionTest.php
index 010e75e..df5b317 100644
--- a/modules/promotion/tests/src/Functional/CouponRedemptionTest.php
+++ b/modules/promotion/tests/src/Functional/CouponRedemptionTest.php
@@ -9,6 +9,7 @@
  * Tests the coupon redemption form element.
  *
  * @group commerce
+ * @group commerce_promotion
  */
 class CouponRedemptionTest extends CommerceBrowserTestBase {
 
@@ -88,7 +89,7 @@ protected function setUp() {
       'stores' => [$this->store->id()],
       'status' => TRUE,
       'offer' => [
-        'target_plugin_id' => 'commerce_promotion_order_percentage_off',
+        'target_plugin_id' => 'commerce_promotion_percentage_off',
         'target_plugin_configuration' => [
           'amount' => '0.10',
         ],
diff --git a/modules/promotion/tests/src/FunctionalJavascript/PromotionTest.php b/modules/promotion/tests/src/FunctionalJavascript/PromotionTest.php
index a743eb4..1d31c96 100644
--- a/modules/promotion/tests/src/FunctionalJavascript/PromotionTest.php
+++ b/modules/promotion/tests/src/FunctionalJavascript/PromotionTest.php
@@ -42,8 +42,8 @@ public function testCreatePromotion() {
     // Check the integrity of the form.
     $this->assertSession()->fieldExists('name[0][value]');
 
-    $this->getSession()->getPage()->fillField('offer[0][target_plugin_id]', 'commerce_promotion_product_percentage_off');
-    $this->getSession()->wait(2000, "jQuery('.ajax-progress').length === 0");
+    $this->getSession()->getPage()->fillField('offer[0][target_plugin_id]', 'commerce_promotion_percentage_off');
+    $this->waitForAjaxToFinish();
 
     $name = $this->randomMachineName(8);
     $edit = [
@@ -52,7 +52,7 @@ public function testCreatePromotion() {
     ];
 
     $this->getSession()->getPage()->fillField('conditions[0][target_plugin_id]', 'commerce_promotion_order_total_price');
-    $this->getSession()->wait(2000, "jQuery('.ajax-progress').length === 0");
+    $this->waitForAjaxToFinish();
 
     $edit['conditions[0][target_plugin_configuration][amount][number]'] = '50.00';
 
@@ -77,8 +77,8 @@ public function testCreatePromotionWithEndDate() {
     // Check the integrity of the form.
     $this->assertSession()->fieldExists('name[0][value]');
 
-    $this->getSession()->getPage()->fillField('offer[0][target_plugin_id]', 'commerce_promotion_product_percentage_off');
-    $this->getSession()->wait(2000, "jQuery('.ajax-progress').length === 0");
+    $this->getSession()->getPage()->fillField('offer[0][target_plugin_id]', 'commerce_promotion_percentage_off');
+    $this->waitForAjaxToFinish();
 
     $name = $this->randomMachineName(8);
     $edit = [
@@ -87,7 +87,7 @@ public function testCreatePromotionWithEndDate() {
     ];
 
     $this->getSession()->getPage()->fillField('conditions[0][target_plugin_id]', 'commerce_promotion_order_total_price');
-    $this->getSession()->wait(2000, "jQuery('.ajax-progress').length === 0");
+    $this->waitForAjaxToFinish();
 
     $edit['conditions[0][target_plugin_configuration][amount][number]'] = '50.00';
 
@@ -113,7 +113,7 @@ public function testEditPromotion() {
       'name' => $this->randomMachineName(8),
       'status' => TRUE,
       'offer' => [
-        'target_plugin_id' => 'commerce_promotion_product_percentage_off',
+        'target_plugin_id' => 'commerce_promotion_percentage_off',
         'target_plugin_configuration' => [
           'amount' => '0.10',
         ],
@@ -132,7 +132,7 @@ public function testEditPromotion() {
     ];
     $this->submitForm($edit, 'Save');
 
-    \Drupal::service('entity_type.manager')->getStorage('commerce_promotion')->resetCache([$promotion->id()]);
+    $this->container->get('entity_type.manager')->getStorage('commerce_promotion')->resetCache([$promotion->id()]);
     $promotion_changed = Promotion::load($promotion->id());
     $this->assertEquals($new_promotion_name, $promotion_changed->getName(), 'The promotion name successfully updated.');
 
@@ -153,7 +153,7 @@ public function testDeletePromotion() {
     $this->assertSession()->pageTextContains('This action cannot be undone.');
     $this->submitForm([], t('Delete'));
 
-    \Drupal::service('entity_type.manager')->getStorage('commerce_promotion')->resetCache([$promotion->id()]);
+    $this->container->get('entity_type.manager')->getStorage('commerce_promotion')->resetCache([$promotion->id()]);
     $promotion_exists = (bool) Promotion::load($promotion->id());
     $this->assertEmpty($promotion_exists, 'The new promotion has been deleted from the database using UI.');
   }
diff --git a/modules/promotion/tests/src/Kernel/CouponOrderIntegrationTest.php b/modules/promotion/tests/src/Kernel/CouponOrderIntegrationTest.php
index 6ea1b61..083f193 100644
--- a/modules/promotion/tests/src/Kernel/CouponOrderIntegrationTest.php
+++ b/modules/promotion/tests/src/Kernel/CouponOrderIntegrationTest.php
@@ -11,6 +11,7 @@
  * Tests coupon integration with orders.
  *
  * @group commerce
+ * @group commerce_promotion
  */
 class CouponOrderIntegrationTest extends CommerceKernelTestBase {
 
diff --git a/modules/promotion/tests/src/Kernel/CouponStorageTest.php b/modules/promotion/tests/src/Kernel/CouponStorageTest.php
index 389e5a7..edcb496 100644
--- a/modules/promotion/tests/src/Kernel/CouponStorageTest.php
+++ b/modules/promotion/tests/src/Kernel/CouponStorageTest.php
@@ -9,6 +9,7 @@
  * Tests coupon storage.
  *
  * @group commerce
+ * @group commerce_promotion
  */
 class CouponStorageTest extends CommerceKernelTestBase {
 
diff --git a/modules/promotion/tests/src/Kernel/CouponValidationTest.php b/modules/promotion/tests/src/Kernel/CouponValidationTest.php
index cb8e58f..8e65c6a 100644
--- a/modules/promotion/tests/src/Kernel/CouponValidationTest.php
+++ b/modules/promotion/tests/src/Kernel/CouponValidationTest.php
@@ -10,6 +10,7 @@
  * Tests coupon validation constraints.
  *
  * @group commerce
+ * @group commerce_promotion
  */
 class CouponValidationTest extends EntityKernelTestBase {
 
diff --git a/modules/promotion/tests/src/Kernel/CouponsFieldPostUpdateTest.php b/modules/promotion/tests/src/Kernel/CouponsFieldPostUpdateTest.php
index e32ad25..d42071f 100644
--- a/modules/promotion/tests/src/Kernel/CouponsFieldPostUpdateTest.php
+++ b/modules/promotion/tests/src/Kernel/CouponsFieldPostUpdateTest.php
@@ -10,6 +10,7 @@
  * Tests coupon field definition updated to orders.
  *
  * @group commerce
+ * @group commerce_promotion
  */
 class CouponsFieldPostUpdateTest extends CommerceKernelTestBase {
 
diff --git a/modules/promotion/tests/src/Kernel/PromotionConditionTest.php b/modules/promotion/tests/src/Kernel/PromotionConditionTest.php
index 8627e45..f83397d 100644
--- a/modules/promotion/tests/src/Kernel/PromotionConditionTest.php
+++ b/modules/promotion/tests/src/Kernel/PromotionConditionTest.php
@@ -12,6 +12,7 @@
  * Tests promotion conditions.
  *
  * @group commerce
+ * @group commerce_promotion
  */
 class PromotionConditionTest extends CommerceKernelTestBase {
 
@@ -101,7 +102,7 @@ public function testOrderTotal() {
       'stores' => [$this->store->id()],
       'status' => TRUE,
       'offer' => [
-        'target_plugin_id' => 'commerce_promotion_order_percentage_off',
+        'target_plugin_id' => 'commerce_promotion_percentage_off',
         'target_plugin_configuration' => [
           'amount' => '0.10',
         ],
@@ -120,8 +121,9 @@ public function testOrderTotal() {
     ]);
     $promotion->save();
 
-    $result = $promotion->applies($this->order);
-    $this->assertNotEmpty($result);
+
+    $this->assertTrue($promotion->applies($this->order));
+    $this->assertFalse($promotion->applies($order_item));
 
     $promotion = Promotion::create([
       'name' => 'Promotion 1',
@@ -129,7 +131,7 @@ public function testOrderTotal() {
       'stores' => [$this->store->id()],
       'status' => TRUE,
       'offer' => [
-        'target_plugin_id' => 'commerce_promotion_order_percentage_off',
+        'target_plugin_id' => 'commerce_promotion_percentage_off',
         'target_plugin_configuration' => [
           'amount' => '0.10',
         ],
@@ -148,9 +150,8 @@ public function testOrderTotal() {
     ]);
     $promotion->save();
 
-    $result = $promotion->applies($this->order);
-
-    $this->assertEmpty($result);
+    $this->assertFalse($promotion->applies($this->order));
+    $this->assertFalse($promotion->applies($order_item));
   }
 
 }
diff --git a/modules/promotion/tests/src/Kernel/PromotionOfferTest.php b/modules/promotion/tests/src/Kernel/PromotionOfferTest.php
index 4e55d6a..c57429f 100644
--- a/modules/promotion/tests/src/Kernel/PromotionOfferTest.php
+++ b/modules/promotion/tests/src/Kernel/PromotionOfferTest.php
@@ -13,6 +13,7 @@
  * Tests promotion offers.
  *
  * @group commerce
+ * @group commerce_promotion
  */
 class PromotionOfferTest extends CommerceKernelTestBase {
 
@@ -103,7 +104,7 @@ public function testOrderPercentageOff() {
       'stores' => [$this->store->id()],
       'status' => TRUE,
       'offer' => [
-        'target_plugin_id' => 'commerce_promotion_order_percentage_off',
+        'target_plugin_id' => 'commerce_promotion_percentage_off',
         'target_plugin_configuration' => [
           'amount' => '0.10',
         ],
@@ -144,7 +145,7 @@ public function testProductPercentageOff() {
       'stores' => [$this->store->id()],
       'status' => TRUE,
       'offer' => [
-        'target_plugin_id' => 'commerce_promotion_product_percentage_off',
+        'target_plugin_id' => 'commerce_promotion_percentage_off',
         'target_plugin_configuration' => [
           'amount' => '0.50',
         ],
@@ -174,7 +175,7 @@ public function testProductPercentageOff() {
   }
 
   /**
-   * Tests offer target entity type.
+   * Tests that a promotion applies regardless.
    */
   public function testTargetType() {
     // Use addOrderItem so the total is calculated.
@@ -195,7 +196,7 @@ public function testTargetType() {
       'stores' => [$this->store->id()],
       'status' => TRUE,
       'offer' => [
-        'target_plugin_id' => 'commerce_promotion_order_percentage_off',
+        'target_plugin_id' => 'commerce_promotion_percentage_off',
         'target_plugin_configuration' => [
           'amount' => '0.10',
         ],
@@ -214,7 +215,7 @@ public function testTargetType() {
       'stores' => [$this->store->id()],
       'status' => TRUE,
       'offer' => [
-        'target_plugin_id' => 'commerce_promotion_product_percentage_off',
+        'target_plugin_id' => 'commerce_promotion_percentage_off',
         'target_plugin_configuration' => [
           'amount' => '0.50',
         ],
@@ -222,10 +223,10 @@ public function testTargetType() {
     ]);
     $promotion->save();
 
-    $result = $promotion->applies($this->order);
+    $result = $promotion->applies($order_item);
 
-    // Promotion target is for the order items. This should fail.
-    $this->assertEmpty($result);
+    // Applies to both order and order items unless conditions say otherwise.
+    $this->assertTrue($result);
   }
 
 }
diff --git a/modules/promotion/tests/src/Kernel/PromotionOrderProcessorTest.php b/modules/promotion/tests/src/Kernel/PromotionOrderProcessorTest.php
index cdbd872..87a2135 100644
--- a/modules/promotion/tests/src/Kernel/PromotionOrderProcessorTest.php
+++ b/modules/promotion/tests/src/Kernel/PromotionOrderProcessorTest.php
@@ -14,6 +14,7 @@
  * Tests the promotion order processor.
  *
  * @group commerce
+ * @group commerce_promotion
  */
 class PromotionOrderProcessorTest extends CommerceKernelTestBase {
 
@@ -100,7 +101,7 @@ public function testOrderTotal() {
       'stores' => [$this->store->id()],
       'status' => TRUE,
       'offer' => [
-        'target_plugin_id' => 'commerce_promotion_order_percentage_off',
+        'target_plugin_id' => 'commerce_promotion_percentage_off',
         'target_plugin_configuration' => [
           'amount' => '0.10',
         ],
@@ -150,7 +151,7 @@ public function testCouponPromotion() {
       'stores' => [$this->store->id()],
       'status' => TRUE,
       'offer' => [
-        'target_plugin_id' => 'commerce_promotion_order_percentage_off',
+        'target_plugin_id' => 'commerce_promotion_percentage_off',
         'target_plugin_configuration' => [
           'amount' => '0.10',
         ],
diff --git a/modules/promotion/tests/src/Kernel/PromotionStorageTest.php b/modules/promotion/tests/src/Kernel/PromotionStorageTest.php
index ea112a7..2134ad7 100644
--- a/modules/promotion/tests/src/Kernel/PromotionStorageTest.php
+++ b/modules/promotion/tests/src/Kernel/PromotionStorageTest.php
@@ -12,6 +12,7 @@
  * Tests promotion storage.
  *
  * @group commerce
+ * @group commerce_promotion
  */
 class PromotionStorageTest extends CommerceKernelTestBase {
 
diff --git a/src/Element/PluginSelect.php b/src/Element/PluginSelect.php
index 5146fc4..95ce1a3 100644
--- a/src/Element/PluginSelect.php
+++ b/src/Element/PluginSelect.php
@@ -127,6 +127,10 @@ public static function processPluginSelect(&$element, FormStateInterface $form_s
       $plugin = $plugin_manager->createInstance($target_plugin_id, $values['target_plugin_configuration']);
       if ($plugin instanceof PluginFormInterface) {
         $element['target_plugin_configuration'] = $plugin->buildConfigurationForm($element['target_plugin_configuration'], $form_state);
+        // Need parents modification for proper ajax handle.
+        $element['target_plugin_configuration']['#tree'] = TRUE;
+        $element['target_plugin_configuration']['#parents'] = array_merge($element['#parents'], ['target_plugin_configuration']);
+        $element['target_plugin_configuration']['#array_parents'] = array_merge($element['#array_parents'], ['target_plugin_configuration']);
       }
     }
 
