You are here

commerce_discount_extra.module in Commerce Discount Extra 7

Provides necessary inline conditions and support for extra discounts.

File

commerce_discount_extra.module
View source
<?php

/**
 * @file
 * Provides necessary inline conditions and support for extra discounts.
 */

/**
 * Implements hook_menu().
 */
function commerce_discount_extra_menu() {
  $items = array();
  $items['commerce_discount_extra/categories_autocomplete/%'] = array(
    'title' => 'Product categories autocomplete',
    'page callback' => 'commerce_discount_extra_offer_categories_autocomplete_callback',
    'page arguments' => array(
      2,
      3,
    ),
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
  );
  return $items;
}

/**
 * Implements hook_inline_conditions_info().
 */
function commerce_discount_extra_inline_conditions_info() {

  // User has role: line item.
  $conditions['commerce_discount_extra_line_item_user_has_role'] = array(
    'label' => t('Role'),
    'entity type' => 'commerce_line_item',
    'callbacks' => array(
      'configure' => 'commerce_discount_extra_user_has_role_configure',
      'build' => 'commerce_discount_extra_user_has_role_build',
    ),
  );

  // User has role: order.
  $conditions['commerce_discount_extra_order_user_has_role'] = array(
    'label' => t('Role'),
    'entity type' => 'commerce_order',
    'callbacks' => array(
      'configure' => 'commerce_discount_extra_user_has_role_configure',
      'build' => 'commerce_discount_extra_user_has_role_build',
    ),
  );

  // Product price: line item.
  $conditions['commerce_discount_extra_line_item_price_comp'] = array(
    'label' => t('Product price'),
    'entity type' => 'commerce_line_item',
    'callbacks' => array(
      'configure' => 'commerce_discount_extra_line_item_price_comp_configure',
      'build' => 'commerce_discount_extra_line_item_price_comp_build',
    ),
  );

  // Total items in order: line item.
  $conditions['commerce_discount_extra_line_item_items_in_order'] = array(
    'label' => t('Total items in order'),
    'entity type' => 'commerce_line_item',
    'callbacks' => array(
      'configure' => 'commerce_discount_extra_items_in_order_configure',
      'build' => 'commerce_discount_extra_items_in_order_build',
    ),
  );

  // Total items in order: order.
  $conditions['commerce_discount_extra_order_items_in_order'] = array(
    'label' => t('Total items in order'),
    'entity type' => 'commerce_order',
    'callbacks' => array(
      'configure' => 'commerce_discount_extra_items_in_order_configure',
      'build' => 'commerce_discount_extra_items_in_order_build',
    ),
  );

  // Products and quantity: line item.
  $conditions['commerce_discount_extra_line_item_has_specific_quantity_products'] = array(
    'label' => t('Order has product(s) and quantity'),
    'entity type' => 'commerce_line_item',
    'callbacks' => array(
      'configure' => 'commerce_order_has_specific_quantity_products_configure',
      'build' => 'commerce_discount_extra_line_item_has_specific_quantity_products_build',
    ),
  );

  // Order total: line item.
  $conditions['commerce_discount_extra_line_item_compare_order_amount'] = array(
    'label' => t('Order amount'),
    'entity type' => 'commerce_line_item',
    'callbacks' => array(
      'configure' => 'commerce_order_compare_order_amount_configure',
      'build' => 'commerce_discount_extra_line_item_compare_order_amount_build',
    ),
  );

  // Product type: line item.
  $conditions['commerce_discount_extra_line_item_has_product_type'] = array(
    'label' => t('Product type'),
    'entity type' => 'commerce_line_item',
    'callbacks' => array(
      'configure' => 'commerce_discount_extra_line_item_has_product_type_configure',
      'build' => 'commerce_discount_extra_line_item_has_product_type_build',
    ),
  );
  return $conditions;
}

/**
 * Inline condition build callback: compare line item product type.
 *
 * @param EntityDrupalWrapper $line_item_wrapper
 *   The wrapped entity given by the rule.
 * @param array $product_types
 *   Array of product types.
 *
 * @return bool
 *   Returns TRUE if condition is valid, FALSE otherwise.
 */
function commerce_discount_extra_line_item_has_product_type_build(EntityDrupalWrapper $line_item_wrapper, $product_types) {
  if (in_array($line_item_wrapper
    ->getBundle(), commerce_product_line_item_types()) && in_array($line_item_wrapper->commerce_product
    ->getBundle(), $product_types)) {
    return TRUE;
  }
  return FALSE;
}

/**
 * Inline condition build callback: compare order total for line items.
 *
 * @param EntityDrupalWrapper $line_item_wrapper
 *   The wrapped entity given by the rule.
 * @param string $operator
 *   The comparison operator.
 * @param array $total
 *   A commerce_price type array.
 *
 * @return bool
 *   Returns TRUE if condition is valid, FALSE otherwise.
 */
function commerce_discount_extra_line_item_compare_order_amount_build(EntityDrupalWrapper $line_item_wrapper, $operator, $total, $line_item_types) {
  module_load_include('inc', 'commerce_discount', 'commerce_discount.rules');
  if ($line_item_wrapper->order
    ->value() && $line_item_wrapper->order->order_id
    ->value()) {
    $order = commerce_order_load($line_item_wrapper->order->order_id
      ->value());
    if ($order) {
      $order_wrapper = entity_metadata_wrapper('commerce_order', $order);

      // Need to do this to trigger the total field update.
      $line_item_wrapper
        ->save();

      // Run the order amount comparison function.
      return commerce_order_compare_order_amount_build($order_wrapper, $operator, $total, $line_item_types);
    }
  }
  return FALSE;
}

/**
 * Inline condition build callback: Order contains products.
 *
 * Determine whether the order associated with a line item, either by reference
 * or by user's current cart, contains all of a list of skus.
 *
 * @param EntityDrupalWrapper $line_item_wrapper
 *   The wrapped entity given by the rule.
 * @param array $skus
 *   Array of product SKUs given by the rule.
 *
 * @return bool
 *   Returns TRUE if condition is valid, FALSE otherwise.
 */
function commerce_discount_extra_line_item_order_contains_products_build(EntityDrupalWrapper $line_item_wrapper, $skus) {
  $products_sku = explode(', ', (string) $skus);

  // If the line item has an order, run the "order has products" logic with that
  // as the argument.
  if ($line_item_wrapper->order
    ->value()) {
    $order_wrapper = $line_item_wrapper->order;
  }

  // If the line item does not have an order (the most common reason is that it
  // is a line item spawned by a calculated price field display), use the user's
  // current cart as the subject for the product search.
  global $user;
  $cart_order = commerce_cart_order_load($user->uid);
  if ($cart_order) {
    $order_wrapper = entity_metadata_wrapper('commerce_order', $cart_order);
  }
  if (isset($order_wrapper)) {

    // Compare discount product ids with those of given order.
    foreach ($order_wrapper->commerce_line_items as $wrapper_line_item) {

      // Ensures that the type of current line item is product.
      if ($wrapper_line_item
        ->getBundle() == 'product') {
        if (($key = array_search($wrapper_line_item->commerce_product->sku
          ->value(), $products_sku)) !== FALSE) {
          unset($products_sku[$key]);
        }
      }
    }
    return empty($products_sku);
  }
  return FALSE;
}

/**
 * Inline conditions build callback: Determine if a user has a role.
 *
 * Discount-type agnostic.
 *
 * @param EntityDrupalWrapper $wrapper
 *   The wrapped entity given by the rule.
 * @param array $roles
 *   Array of roles given by the rule.
 *
 * @return bool
 *   Returns TRUE if condition is valid, FALSE otherwise.
 */
function commerce_discount_extra_user_has_role_build(EntityDrupalWrapper $wrapper, $roles) {
  global $user;

  // Get the order's owner.
  switch ($wrapper
    ->type()) {
    case 'commerce_order':
      if ($wrapper->owner
        ->value()) {
        $account = $wrapper->owner
          ->value();
      }
      break;
    case 'commerce_line_item':
      if ($wrapper->order
        ->value() && $wrapper->order->owner
        ->value()) {
        $account = $wrapper->order->owner
          ->value();
      }
  }

  // If the account is not yet saved to the order, use the current user.
  // This won't affect orders of anonymous users because they will have a 0 uid
  // as their owner.
  if (!isset($account)) {
    $account = $user;
  }
  if (empty($account->roles)) {
    return FALSE;
  }

  // Match role by name.
  $user_roles = array_values($user->roles);
  return (bool) array_intersect($roles, $user_roles);
}

/**
 * Inline conditions build callback: line item product comparison.
 *
 * @param EntityDrupalWrapper $line_item_wrapper
 *   The wrapped entity given by the rule.
 * @param array $price
 *   Price array structure.
 * @param string $operator
 *   The comparison operator.
 * @param string $method
 *   Calculation method.
 *
 * @return bool
 *   Returns TRUE if condition is valid, FALSE otherwise.
 */
function commerce_discount_extra_line_item_price_comp_build(EntityDrupalWrapper $line_item_wrapper, $price, $operator, $method) {

  // Ensure that it is a product line item.
  if (!in_array($line_item_wrapper
    ->getBundle(), commerce_product_line_item_types())) {
    return FALSE;
  }

  // Select the appropriate line item price based on the comparison method.
  switch ($method) {
    case 'base':

      // Get product base price.
      $line_item_price = $line_item_wrapper->commerce_product->commerce_price
        ->value();
      break;
    case 'calculated':

      // Get line item total.
      $line_item_price = $line_item_wrapper->commerce_total
        ->value();
      break;

    // Invalid method.
    default:
      return FALSE;
  }

  // Convert the comparison amount to the line item's currency to make an even
  // comparison.
  $comparison_price_amount = commerce_currency_convert($price['amount'], $price['currency_code'], $line_item_price['currency_code']);

  // Evaluate expression.
  return _commerce_discount_extra_expression_eval($line_item_price['amount'], $comparison_price_amount, $operator);
}

/**
 * Inline conditions build callback: evaluate items in order condition.
 *
 * @param EntityDrupalWrapper $entity_wrapper
 *   The wrapped entity given by the rule.
 * @param int $number
 *   The number given by the rule.
 * @param string $operator
 *   The comparison operator.
 *
 * @return bool
 *   Returns TRUE if condition is valid, FALSE otherwise.
 */
function commerce_discount_extra_items_in_order_build(EntityDrupalWrapper $entity_wrapper, $number, $operator) {

  // Find the order depending on if the argument is an order or a line item.
  if ($entity_wrapper
    ->type() == 'commerce_order') {
    $order_wrapper = $entity_wrapper;
  }
  elseif ($entity_wrapper->order
    ->value()) {
    $order_wrapper = $entity_wrapper->order;
  }
  $qty = 0;

  // Determine total item count on order.
  if (isset($order_wrapper)) {
    foreach ($order_wrapper->commerce_line_items as $line_item_wrapper) {
      if (in_array($line_item_wrapper
        ->getBundle(), commerce_product_line_item_types())) {
        $qty += $line_item_wrapper->quantity
          ->value();
      }
    }
  }

  // Evaluate.
  return _commerce_discount_extra_expression_eval($qty, $number, $operator);
}

/**
 * Inline conditions build callback: evaluate product quantity conditions.
 *
 * @param EntityDrupalWrapper $line_item_wrapper
 *   The wrapped entity given by the rule.
 * @param array $products
 *   The array of products given by the rule.
 * @param string $operator
 *   The comparison operator.
 * @param int $quantity
 *   The quantity given by the rule.
 *
 * @return bool
 *   Returns TRUE if condition is valid, FALSE otherwise.
 */
function commerce_discount_extra_line_item_has_specific_quantity_products_build(EntityDrupalWrapper $line_item_wrapper, $products, $operator, $quantity) {
  module_load_include('inc', 'commerce_discount', 'commerce_discount.rules');
  return commerce_order_has_specific_quantity_products_build($line_item_wrapper->order, $products, $operator, $quantity);
}

/**
 * Inline conditions configure callback.
 *
 * Configure role selection form.
 *
 * @param array $settings
 *   An array of rules condition settings.
 *
 * @return array
 *   Return a form build array.
 */
function commerce_discount_extra_user_has_role_configure($settings) {
  $form['roles'] = array(
    '#type' => 'select',
    '#title' => t('Select role(s)'),
    '#options' => array_combine(user_roles(TRUE), user_roles(TRUE)),
    '#default_value' => isset($settings['roles']) ? $settings['roles'] : array(),
    '#required' => TRUE,
    '#multiple' => TRUE,
  );
  return $form;
}

/**
 * Inline conditions configure callback.
 *
 * Configure line item price comp form.
 *
 * @param array $settings
 *   An array of rules condition settings.
 *
 * @return array
 *   Return a form build array.
 */
function commerce_discount_extra_line_item_price_comp_configure($settings) {
  module_load_include('inc', 'commerce_discount', 'commerce_discount.rules');

  // Expression operator.
  $form['operator'] = array(
    '#type' => 'select',
    '#title' => t('Operator'),
    '#title_display' => 'invisible',
    '#options' => _commerce_discount_operator_options(),
    '#default_value' => !empty($settings['operator']) ? $settings['operator'] : '==',
    '#required' => TRUE,
  );

  // Price container.
  $form['price'] = array(
    '#type' => 'container',
    '#tree' => TRUE,
    '#element_validate' => array(
      'commerce_price_field_widget_validate',
    ),
    '#suffix' => '<div class="condition-instructions">' . t('Enter a value to compare to the product price.') . '</div>',
  );

  // Price amount.
  $form['price']['amount'] = array(
    '#type' => 'textfield',
    '#title' => t('Product price'),
    '#title_display' => 'invisible',
    '#default_value' => commerce_currency_amount_to_decimal($settings['price']['amount'], commerce_default_currency()),
    '#size' => 10,
    '#field_suffix' => commerce_default_currency(),
    '#require' => TRUE,
  );

  // Price currency (hidden).
  $form['price']['currency_code'] = array(
    '#type' => 'value',
    '#default_value' => commerce_default_currency(),
  );

  // Comparison method.
  $form['method'] = array(
    '#type' => 'radios',
    '#title' => t('Compare using'),
    '#options' => _commerce_discount_extra_comp_method_options(),
    '#default_value' => !empty($settings['method']) ? $settings['method'] : 'base',
    '#required' => TRUE,
  );
  return $form;
}

/**
 * Implements hook_commerce_discount_offer_type_info().
 */
function commerce_discount_extra_commerce_discount_offer_type_info() {
  $types['per_quantity_fixed'] = array(
    'label' => t('Per-quantity product discount ($ off)'),
    'action' => 'commerce_discount_extra_per_quantity_discount',
    'entity types' => array(
      'commerce_order',
    ),
  );
  $types['per_quantity_percentage'] = array(
    'label' => t('Per-quantity product discount (% off)'),
    'action' => 'commerce_discount_extra_per_quantity_discount',
    'entity types' => array(
      'commerce_order',
    ),
  );
  $types['fixed_product_price'] = array(
    'label' => t('Fixed price'),
    'action' => 'commerce_discount_extra_fixed_product_price',
    'entity types' => array(
      'commerce_line_item',
    ),
  );
  $types['per_quantity_category_fixed'] = array(
    'label' => t('Per-quantity category discount ($ off)'),
    'action' => 'commerce_discount_extra_per_quantity_category_discount',
    'entity types' => array(
      'commerce_order',
    ),
  );
  $types['per_quantity_category_percentage'] = array(
    'label' => t('Per-quantity category discount (% off)'),
    'action' => 'commerce_discount_extra_per_quantity_category_discount',
    'entity types' => array(
      'commerce_order',
    ),
  );
  return $types;
}

/**
 * Inline conditions configure callback: configure items in order condition.
 *
 * @param array $settings
 *   An array of rules condition settings.
 *
 * @return array
 *   Return a form build array.
 */
function commerce_discount_extra_items_in_order_configure($settings) {
  module_load_include('inc', 'commerce_discount', 'commerce_discount.rules');

  // Expression operator.
  $form['operator'] = array(
    '#type' => 'select',
    '#title' => t('Operator'),
    '#title_display' => 'invisible',
    '#options' => _commerce_discount_operator_options(),
    '#default_value' => !empty($settings['operator']) ? $settings['operator'] : '==',
    '#required' => TRUE,
  );

  // Comparison number.
  $form['number'] = array(
    '#type' => 'textfield',
    '#title' => t('Number'),
    '#default_value' => !empty($settings['number']) ? $settings['number'] : '',
    '#element_validate' => array(
      'element_validate_integer',
    ),
    '#required' => TRUE,
  );
  return $form;
}

/**
 * Inline conditions configure callback: configure product type condition.
 *
 * @param array $settings
 *   An array of rules condition settings.
 *
 * @return array
 *   Return a form build array.
 */
function commerce_discount_extra_line_item_has_product_type_configure($settings) {
  $form['product_types'] = array(
    '#type' => 'select',
    '#multiple' => TRUE,
    '#title' => t('Product types'),
    '#description' => t('Select the product types that should get this discount'),
    '#default_value' => !empty($settings['product_types']) ? $settings['product_types'] : array(),
    '#options' => commerce_product_type_options_list(),
  );
  return $form;
}

/**
 * Implements hook_flush_caches().
 */
function commerce_discount_extra_flush_caches() {
  module_load_install('commerce_discount_extra');
  commerce_discount_extra_install();
}

/**
 * Implements hook_inline_conditions_build_alter().
 */
function commerce_discount_extra_inline_conditions_build_alter(&$value) {
  switch ($value['condition_name']) {
    case 'commerce_discount_extra_line_item_has_specific_quantity_products':

      // See commerce_order_inline_conditions_build_alter().
      $entity_ids = array();
      foreach ($value['condition_settings']['products'] as $delta) {
        $entity_ids[] = reset($delta);
      }
      $products = commerce_product_load_multiple($entity_ids);
      $value['condition_settings']['products'] = '';
      foreach ($products as $product) {
        $value['condition_settings']['products'] .= $product->sku;
        if ($product !== end($products)) {
          $value['condition_settings']['products'] .= ', ';
        }
      }
      break;
  }
}

/**
 * Returns a list of comparison method options.
 *
 * @return array
 *   Comparison menthod options.
 */
function _commerce_discount_extra_comp_method_options() {
  return array(
    'base' => t('Product base price'),
    'calculated' => t('Calculated price'),
  );
}

/**
 * Get the quantity of a given product in an order.
 *
 * @param EntityDrupalWrapper $order_wrapper
 *   An order entity wrapper.
 * @param array $product_ids
 *   Array of product IDs.
 *
 * @return int
 *   The quantity of a set of products on an order.
 */
function commerce_discount_extra_order_product_qty(EntityDrupalWrapper $order_wrapper, $product_ids) {
  $qty = 0;
  foreach ($order_wrapper->commerce_line_items as $line_item_wrapper) {
    if ($line_item_wrapper
      ->value() && in_array($line_item_wrapper
      ->getBundle(), commerce_product_line_item_types()) && in_array($line_item_wrapper->commerce_product
      ->getIdentifier(), $product_ids)) {
      $qty += $line_item_wrapper->quantity
        ->value();
    }
  }
  return $qty;
}

/**
 * Evalute a simple expression with two values and an operator.
 *
 * @param int $v1
 *   First value to compare.
 * @param int $v2
 *   Second value to compare.
 * @param string $operator
 *   The comparison operator.
 *
 * @return bool
 *   Returns TRUE if condition is valid, FALSE otherwise.
 */
function _commerce_discount_extra_expression_eval($v1, $v2, $operator) {
  switch ($operator) {
    case '>':
      $result = $v1 > $v2;
      break;
    case '>=':
      $result = $v1 >= $v2;
      break;
    case '<':
      $result = $v1 < $v2;
      break;
    case '<=':
      $result = $v1 <= $v2;
      break;
    case '==':
      $result = $v1 == $v2;
      break;
  }
  return $result;
}

/**
 * Implements hook_form_FORM_ID_alter().
 */
function commerce_discount_extra_form_commerce_discount_form_alter(&$form, &$form_state) {

  // Inline entity form has a problem where it interprets a blank integer field
  // as a string rather than an integer. This causes a fatal error on write, as
  // '' cannot be written to an integer column, even if it accepts NULL values.
  if (isset($form['commerce_discount_fields']['commerce_discount_offer'][LANGUAGE_NONE]['form']['commerce_discount_offer_fields']['commerce_offer_limit'][LANGUAGE_NONE][0]['value'])) {
    $element =& $form['commerce_discount_fields']['commerce_discount_offer'][LANGUAGE_NONE]['form']['commerce_discount_offer_fields']['commerce_offer_limit'][LANGUAGE_NONE][0]['value'];

    // Add an element validate handler that converts empty values to 0 during
    // the element validation step.
    $element['#element_validate'][] = 'commerce_discount_extra_offer_limit_element_validate';

    // Additionally, if the current default value of the element is 0, convert
    // it to an empty string for dubious UX benefit.
    // @todo Update the interface text to say use 0 or leave blank for no limit.
    if (empty($element['#default_value'])) {
      $element['#default_value'] = '';
    }
  }

  // We use a "Long text" field to identify trigger and offer categories, but we
  // alter it to an autocomplete textfield against the allowed categories for an
  // improved user experience over a multi-value select list or similar field.
  foreach (array(
    'commerce_trigger_categories',
    'commerce_offer_categories',
  ) as $field_name) {
    if (isset($form['commerce_discount_fields']['commerce_discount_offer'][LANGUAGE_NONE]['form']['commerce_discount_offer_fields'][$field_name][LANGUAGE_NONE][0]['value'])) {
      $element =& $form['commerce_discount_fields']['commerce_discount_offer'][LANGUAGE_NONE]['form']['commerce_discount_offer_fields'][$field_name][LANGUAGE_NONE][0]['value'];
      $element['#type'] = 'textfield';
      $element['#maxlength'] = 4096;
      $element['#size'] = 120;
      $element['#autocomplete_path'] = 'commerce_discount_extra/categories_autocomplete/0';
      $element['#element_validate'][] = '_commerce_discount_extra_offer_categories_autocomplete_validate';
      $element['#default_value'] = commerce_discount_extra_offer_categories_default_value($element['#default_value']);
    }
  }
}

/**
 * Form validate callback: change empty string to integer for limit field.
 */
function commerce_discount_extra_offer_limit_element_validate(&$element, &$form_state) {

  // If the offer limit field has an empty value, set it to 0 for save.
  if (empty($element['#value'])) {
    form_set_value($element, 0, $form_state);
  }
}

/**
 * Returns an offer categories default value including taxonomy term names.
 */
function commerce_discount_extra_offer_categories_default_value($default_value) {
  $tids = explode(', ', $default_value);
  $new_default = '';

  // Loop over all loaded terms based on the current default value.
  foreach (taxonomy_term_load_multiple($tids) as $tid => $term) {
    if (!empty($new_default)) {
      $new_default .= ', ';
    }
    $new_default .= $term->name . ' (' . $tid . ')';
  }
  return $new_default;
}

/**
 * Menu callback: returns autocomplete values for BOGO offer categories defined
 * as taxonomy terms from vocabularies referenced by product display node types.
 *
 * @param bool $single
 *   TRUE if the autocomplete only supports a single value; defaults to FALSE.
 * @param string $string
 *   The current label string to search against.
 */
function commerce_discount_extra_offer_categories_autocomplete_callback($single = FALSE, $string = '') {
  $matches = array();

  // If the autocomplete should only search for a single value...
  if (!empty($single)) {

    // Use the currently entered string as the "last" label to search against.
    $tag_last = $string;
  }
  else {

    // Otherwise, we expect the user to enter a comma separated list of labels
    // and only autocomplete against the last label.
    $tags_typed = drupal_explode_tags($string);
    $tag_last = drupal_strtolower(array_pop($tags_typed));
    if (!empty($tag_last)) {
      $prefix = count($tags_typed) ? implode(', ', $tags_typed) . ', ' : '';
    }
  }

  // Limit the query to terms in vocabularies referenced by product displays.
  $vocabularies = commerce_discount_extra_product_display_vocabularies();
  if (!empty($tag_last) && !empty($vocabularies)) {
    $entity_info = entity_get_info('taxonomy_term');
    $query = new EntityFieldQuery();
    $query
      ->entityCondition('entity_type', 'taxonomy_term')
      ->propertyCondition($entity_info['entity keys']['label'], $tag_last, 'CONTAINS')
      ->propertyCondition('vid', array_keys($vocabularies))
      ->addTag('taxonomy_term_access')
      ->addTag('commerce_discount_taxonomy_term_query')
      ->range(0, 10);
    $results = $query
      ->execute();
    if (!empty($results['taxonomy_term'])) {
      $entities = entity_load('taxonomy_term', array_keys($results['taxonomy_term']));
      foreach ($entities as $entity_id => $entity) {
        $label = entity_label('taxonomy_term', $entity);
        $label = check_plain($label);
        $key = "{$label} ({$entity_id})";

        // Strip things like starting/trailing white spaces,
        // line breaks and tags.
        $key = preg_replace('/\\s\\s+/', ' ', str_replace("\n", '', trim(decode_entities(strip_tags($key)))));

        // Names containing commas or quotes must be wrapped in quotes.
        if (strpos($key, ',') !== FALSE || strpos($key, '"') !== FALSE) {
          $key = '"' . str_replace('"', '""', $key) . '"';
        }
        $matches[$prefix . $key] = '<div class="reference-autocomplete">' . $label . '</div>';
      }
    }
  }
  drupal_json_output($matches);
}

/**
 * Element validate handler: converts a categories autocomplete value from term
 * names and IDs just to a set of term IDs.
 */
function _commerce_discount_extra_offer_categories_autocomplete_validate($element, &$form_state, $form) {
  $value = array();

  // If a value was entered into the autocomplete...
  if (!empty($element['#value'])) {
    $tags = drupal_explode_tags($element['#value']);
    $tids = array();
    foreach ($tags as $tag) {

      // Extract the term IDs from a tag formatted as "Label (term ID)".
      if (preg_match("/.+\\((\\d+)\\)/", $tag, $matches)) {
        $tids[] = $matches[1];
      }
    }
  }

  // Update the value of this element with only the term IDs.
  form_set_value($element, implode(', ', $tids), $form_state);
}

/**
 * Implements hook_commerce_cart_product_add().
 *
 * When a product is added to the cart, this function looks for any discounts
 * that use per-quantity offers configured to automatically add their offer
 * products to the cart when enough triggering products have been added.
 */
function commerce_discount_extra_commerce_cart_product_add($order, $product, $quantity, $line_item) {

  // See if a product has already been added through this function. If so, bail
  // as we only want to add the first available product to the cart.
  $product_added =& drupal_static(__FUNCTION__);
  if (isset($product_added)) {
    return;
  }
  else {
    $product_added = NULL;
  }

  // Build the base select query to find all per-quantity offer discounts.
  $query = db_select('commerce_discount', 'cd', array(
    'fetch' => PDO::FETCH_ASSOC,
  ))
    ->fields('cd', array(
    'discount_id',
    'name',
  ))
    ->orderBy('cd.discount_id')
    ->groupBy('cd.discount_id');

  // Join to the relevant tables and retain their aliases.
  $fcdo_alias = $query
    ->leftJoin('field_data_commerce_discount_offer', 'fcdo', 'cd.discount_id = %alias.entity_id');
  $cdo_alias = $query
    ->leftJoin('commerce_discount_offer', 'cdo', $fcdo_alias . '.commerce_discount_offer_target_id = %alias.discount_offer_id');
  $fcaab_alias = $query
    ->leftJoin('field_data_commerce_auto_add_behavior', 'fcaab', $cdo_alias . '.discount_offer_id = %alias.entity_id');

  // Add fields from the joined tables.
  $query
    ->fields($cdo_alias, array(
    'type',
  ))
    ->fields($fcaab_alias, array(
    'commerce_auto_add_behavior_value',
  ));

  // Avoid `only_full_group_by` error.
  // @see https://www.drupal.org/node/2782827 for more details.
  $query
    ->groupBy($cdo_alias . '.type');

  // Add the conditions for the various tables.
  $query
    ->condition('cd.status', 1)
    ->condition($fcdo_alias . '.entity_type', 'commerce_discount')
    ->condition($fcaab_alias . '.entity_type', 'commerce_discount_offer')
    ->where($fcaab_alias . ".commerce_auto_add_behavior_value IN ('add_first_offer_product')")
    ->where($cdo_alias . ".type IN ('per_quantity_discount', 'per_quantity_percentage')");

  // Fetch and loop over the results to find discount rules to evaluate.
  $discount_rules = array();
  foreach ($query
    ->execute() as $result) {
    $discount_rules[$result['discount_id']] = 'commerce_discount_rule_' . $result['name'];
  }

  // Load the sell price calculation event.
  $event = rules_get_cache('event_commerce_discount_order');

  // Load all of the relevant discount rules.
  $rules = rules_config_load_multiple($discount_rules);
  $order_wrapper = entity_metadata_wrapper('commerce_order', $order);
  foreach ($discount_rules as $discount_id => $rule_name) {

    // Evaluate each rule's conditions to see if the discount would apply to the
    // current shopping cart.
    $state = new RulesState();
    $vars = $event
      ->parameterInfo(TRUE);
    $state
      ->addVariable('commerce_order', $order, $vars['commerce_order']);
    if ($rules[$rule_name]
      ->conditionContainer()
      ->evaluate($state)) {

      // Load the discount related to the Rule.
      $discount = entity_load('commerce_discount', array(
        $discount_id,
      ));
      $discount = reset($discount);
      $discount_wrapper = entity_metadata_wrapper('commerce_discount', $discount);

      // Check to ensure the product that was added to the cart qualifies as a
      // trigger product for the discount's offer.
      $trigger_product_ids = $discount_wrapper->commerce_discount_offer->commerce_trigger_products
        ->raw();
      if (!empty($trigger_product_ids) && !in_array($product->product_id, $trigger_product_ids)) {
        continue;
      }

      // If any of the current discount's offer products have been added to the
      // cart, continue to the next discount because we don't know for certain
      // one of the products wasn't already added as a result of this discount.
      foreach ($order_wrapper->commerce_line_items as $delta => $line_item_wrapper) {
        $line_item_product_id = $line_item_wrapper->commerce_product
          ->raw();
        if (in_array($line_item_product_id, $discount_wrapper->commerce_discount_offer->commerce_discount_products
          ->raw())) {
          continue 2;
        }
      }

      // If so, see if sufficient trigger products exist in the cart to activate
      // the discount. We do this by maintaining a simplified reversal of the
      // manifest in the discount action's callback: instead of removing items
      // from an array, we add to it as the quantity of various line items on
      // the current order are identified as trigger products and prevent this
      // function from recognizing the same trigger products on multiple offers.
      // Additionally, since we can't be assured the relevant offer product
      // hasn't already been added to the cart, do not include any offer
      // products in the trigger product count.
      $trigger_quantity = $discount_wrapper->commerce_discount_offer->commerce_trigger_qty
        ->raw();
      $offer_quantity = $discount_wrapper->commerce_discount_offer->commerce_offer_qty
        ->raw();
      static $triggering_line_items = array();
      $trigger_count = 0;
      foreach ($order_wrapper->commerce_line_items as $delta => $line_item_wrapper) {
        $line_item_id = $line_item_wrapper
          ->raw();
        $line_item_quantity = $line_item_wrapper->quantity
          ->raw();
        $line_item_product_id = $line_item_wrapper->commerce_product
          ->raw();

        // If the line item has already been fully counted, skip it.
        if (!empty($triggering_line_items[$line_item_id]) && $triggering_line_items[$line_item_id] == $line_item_quantity) {
          continue;
        }

        // If any product may trigger the discount or if the current product is
        // one of the specified trigger products, increment the count as much as
        // needed and log that amount in the $triggering_line_items array.
        $trigger_product_ids = $discount_wrapper->commerce_discount_offer->commerce_trigger_products
          ->raw();
        if (empty($trigger_product_ids) || in_array($line_item_product_id, $trigger_product_ids)) {
          if (empty($trigger_line_items[$line_item_id])) {
            $triggering_line_items[$line_item_id] = 0;
          }
          $increment = min($line_item_quantity - $triggering_line_items[$line_item_id], $trigger_quantity - $trigger_count);
          $triggering_line_items[$line_item_id] += $increment;
          $trigger_count += $increment;

          // If we've reached the trigger quantity, exit the loop.
          if ($trigger_count == $trigger_quantity) {
            break;
          }
        }
      }

      // If by this time, the trigger quantity hasn't been met, do not add any
      // offer product to the cart.
      if ($trigger_count < $trigger_quantity) {
        break;
      }

      // Loop over the discount products looking for one to add.
      foreach ($discount_wrapper->commerce_discount_offer->commerce_discount_products as $delta => $product_wrapper) {

        // Check stock for the current product to see if the full offer amount
        // is available for purchase.
        if (module_exists('commerce_stock')) {

          // Prepare the variables needed for a stock check.
          $quantity_ordered = commerce_stock_check_cart_product_level($product_wrapper
            ->raw());
          $stock_state = '';
          $message = '';

          // Fetch the stock check messages to see if it failed.
          commerce_stock_check_product_rule($product_wrapper
            ->value(), $offer_quantity, $quantity_ordered, $stock_state, $message);

          // If the $stock_state is non-empty, the product is not in stock, so
          // we continue to pass on to the next discounted product.
          if (!empty($stock_state)) {
            continue;
          }
        }

        // Create a line item for the offer product.
        $offer_product_line_item = commerce_product_line_item_new($product_wrapper
          ->value(), $offer_quantity, FALSE);

        // Store this discount's name in the data array.
        $offer_product_line_item->data['discount_name'] = $discount_wrapper->name
          ->value();

        // Add the new line item to the cart.
        commerce_cart_product_add($order->uid, $offer_product_line_item);

        // Reset $product_added so additional discount rules can activate an
        // offer.
        $product_added = NULL;
        break;
      }
    }
  }
}

/**
 * Returns an array of vocabularies referenced through term reference fields on
 * product display node types keyed by vid.
 */
function commerce_discount_extra_product_display_vocabularies() {
  $vocabularies = array();

  // Loop over all product display node types...
  foreach (commerce_product_reference_node_types() as $bundle) {

    // And then loop over all field instances on the node type...
    foreach (field_info_instances('node', $bundle->type) as $instance) {

      // Fetch the field info for the current instance.
      $field = field_info_field($instance['field_name']);

      // If it's a term reference field...
      if ($field['type'] == 'taxonomy_term_reference') {

        // Attempt to load the vocabularies and add them to the return value.
        foreach ($field['settings']['allowed_values'] as $tree) {
          if ($vocabulary = taxonomy_vocabulary_machine_name_load($tree['vocabulary'])) {
            $vocabularies[$vocabulary->vid] = $vocabulary;
          }
        }
      }
    }
  }
  return $vocabularies;
}

/**
 *  Returns the taxonomy term IDs associated with a product via its display(s).
 */
function commerce_discount_extra_product_terms($product_id) {

  // Term IDs will be cached keyed by $product_id.
  $tids =& drupal_static(__FUNCTION__);

  // Cache the product's term IDs if they haven't been set already.
  if (isset($tids[$product_id])) {
    return $tids[$product_id];
  }
  $tids[$product_id] = array();

  // Loop over all known product display node types defined as node types with
  // product reference fields.
  foreach (commerce_product_reference_node_types() as $bundle) {
    $instances = field_info_instances('node', $bundle->type);

    // Look for taxonomy term reference fields on the node type.
    foreach ($instances as $instance) {
      $field = field_info_field($instance['field_name']);

      // If we found one...
      if ($field['type'] == 'taxonomy_term_reference') {

        // Look for nodes of this type that reference our target product.
        $product_reference = key(field_read_fields(array(
          'entity_type' => 'node',
          'type' => 'commerce_product_reference',
          'bundle' => $bundle->type,
        )));

        // @todo Switch from using EFQ to using a direct query of term data.
        $query = new EntityFieldQuery();
        $result = $query
          ->entityCondition('entity_type', 'node')
          ->propertyCondition('type', $bundle->type)
          ->fieldCondition($product_reference, 'product_id', $product_id, '=')
          ->addTag('commerce_discount_node_query')
          ->execute();

        // If we found matching nodes...
        if ($result) {

          // Loop over them and extract the IDs of any terms referenced by the
          // node through the current term reference field.
          foreach ($result['node'] as $node_data) {
            $node = entity_load_single('node', $node_data->nid);
            $term_wrapper = entity_metadata_wrapper('node', $node)->{$field['field_name']};

            // If the term reference field is multi-value, iterate over the
            // items array even if there's only a single referenced term.
            if ($term_wrapper instanceof EntityListWrapper) {
              foreach ($term_wrapper as $item_wrapper) {
                if ($item_wrapper
                  ->value()) {
                  $tids[$product_id][] = $item_wrapper->tid
                    ->value();
                }
              }
            }
            elseif ($term_wrapper
              ->value()) {
              $tids[$product_id][] = $term_wrapper->tid
                ->value();
            }
          }
        }
      }
    }
  }
  return $tids[$product_id];
}

/**
 * Implements hook_commerce_discount_per_quantity_discount_line_item_save().
 *
 * This function will be called when any "Per quantity" discount line item is
 * saved. This is the point at which modules can influence the components of the
 * unit price array for a BOGO discount to do things like manipulate taxes.
 *
 * Note: certain types of BOGO offers may actually require sales tax to be
 * collected, but that's definitely the edge case. A site requiring that will
 * need to implement hook_module_implements_alter() to deregister this hook for
 * now and only call it in the cases when tax should not apply to free products.
 *
 * @see http://blog.taxjar.com/sales-tax-discounts-coupons-promotions/
 */
function commerce_discount_extra_commerce_discount_per_quantity_discount_line_item_save($discount_line_item, $discount_name, $offer_type) {

  // Unset any tax price components from the discount line item's unit price.
  // This allows us to re-add tax price components to this unit price based on
  // the current taxes applied to the original discounted line item.
  $discount_unit_price = $discount_line_item->commerce_unit_price[LANGUAGE_NONE][0];
  foreach ($discount_unit_price['data']['components'] as $key => $component) {

    // The two if statements here are copied from the Commerce Tax module.
    if ($component_type = commerce_price_component_type_load($component['name'])) {
      if (!empty($component_type['tax_rate']) && ($tax_rate = commerce_tax_rate_load($component_type['tax_rate']))) {
        unset($discount_unit_price['data']['components'][$key]);
      }
    }
  }

  // Loop through all discounted line item amounts...
  foreach ($discount_line_item->data['discounted_line_item_amounts'] as $discounted_line_item_id => $discounted_line_item_amount) {

    // Load the discounted line item and extract the unit price.
    $discounted_line_item = commerce_line_item_load($discounted_line_item_id);
    $discounted_unit_price = $discounted_line_item->commerce_unit_price[LANGUAGE_NONE][0];

    // Loop through the price components in the unit price of the original
    // discounted line item looking for tax related price components.
    foreach ($discounted_unit_price['data']['components'] as $component) {
      if ($component_type = commerce_price_component_type_load($component['name'])) {
        if (!empty($component_type['tax_rate']) && ($tax_rate = commerce_tax_rate_load($component_type['tax_rate']))) {

          // If we found one, determine how much tax we need to take off of the
          // discount line item by applying the tax rate to the total amount
          // discounted from the original line item. This captures the scenario
          // where the discounted amount doesn't precisely equal the unit price
          // (e.g. a flat amount off, a percentage off less than the full unit
          // price, or a case where the discount applied multiple times to the
          // same line item).
          $discount_tax_price = array(
            'amount' => -($discounted_line_item_amount * $tax_rate['rate']),
            'currency_code' => $discounted_unit_price['currency_code'],
          );

          // Determine whether or not the related tax is display inclusive.
          $included = FALSE;
          if (($tax_type = commerce_tax_type_load($tax_rate['type'])) && $tax_type['display_inclusive']) {
            $included = TRUE;
          }

          // Add the tax price component to the discount line item's unit price.
          $discount_unit_price['data'] = commerce_price_component_add($discount_unit_price, $tax_rate['price_component'], $discount_tax_price, $included);
        }
      }
    }
  }
  $discount_line_item->commerce_unit_price[LANGUAGE_NONE][0] = $discount_unit_price;
}

Functions

Namesort descending Description
commerce_discount_extra_commerce_cart_product_add Implements hook_commerce_cart_product_add().
commerce_discount_extra_commerce_discount_offer_type_info Implements hook_commerce_discount_offer_type_info().
commerce_discount_extra_commerce_discount_per_quantity_discount_line_item_save Implements hook_commerce_discount_per_quantity_discount_line_item_save().
commerce_discount_extra_flush_caches Implements hook_flush_caches().
commerce_discount_extra_form_commerce_discount_form_alter Implements hook_form_FORM_ID_alter().
commerce_discount_extra_inline_conditions_build_alter Implements hook_inline_conditions_build_alter().
commerce_discount_extra_inline_conditions_info Implements hook_inline_conditions_info().
commerce_discount_extra_items_in_order_build Inline conditions build callback: evaluate items in order condition.
commerce_discount_extra_items_in_order_configure Inline conditions configure callback: configure items in order condition.
commerce_discount_extra_line_item_compare_order_amount_build Inline condition build callback: compare order total for line items.
commerce_discount_extra_line_item_has_product_type_build Inline condition build callback: compare line item product type.
commerce_discount_extra_line_item_has_product_type_configure Inline conditions configure callback: configure product type condition.
commerce_discount_extra_line_item_has_specific_quantity_products_build Inline conditions build callback: evaluate product quantity conditions.
commerce_discount_extra_line_item_order_contains_products_build Inline condition build callback: Order contains products.
commerce_discount_extra_line_item_price_comp_build Inline conditions build callback: line item product comparison.
commerce_discount_extra_line_item_price_comp_configure Inline conditions configure callback.
commerce_discount_extra_menu Implements hook_menu().
commerce_discount_extra_offer_categories_autocomplete_callback Menu callback: returns autocomplete values for BOGO offer categories defined as taxonomy terms from vocabularies referenced by product display node types.
commerce_discount_extra_offer_categories_default_value Returns an offer categories default value including taxonomy term names.
commerce_discount_extra_offer_limit_element_validate Form validate callback: change empty string to integer for limit field.
commerce_discount_extra_order_product_qty Get the quantity of a given product in an order.
commerce_discount_extra_product_display_vocabularies Returns an array of vocabularies referenced through term reference fields on product display node types keyed by vid.
commerce_discount_extra_product_terms Returns the taxonomy term IDs associated with a product via its display(s).
commerce_discount_extra_user_has_role_build Inline conditions build callback: Determine if a user has a role.
commerce_discount_extra_user_has_role_configure Inline conditions configure callback.
_commerce_discount_extra_comp_method_options Returns a list of comparison method options.
_commerce_discount_extra_expression_eval Evalute a simple expression with two values and an operator.
_commerce_discount_extra_offer_categories_autocomplete_validate Element validate handler: converts a categories autocomplete value from term names and IDs just to a set of term IDs.