Currently Being Moderated

Customizing Promotions in Elastic Path 6.2

Posted by Andrew Lau on Feb 11, 2010 6:06:43 PM

The promotion rules engine is one of the most common areas of customization in Elastic Path Commerce. In this article, we'll look at how to add a new rule element to the system. If you're a newcomer to the promotion rules engine, this should give you a pretty good idea of its flexibility. You'll also want to take a look at the developer documentation at http://docs.elasticpath.com/display/EP620DEV/Promotion+rule+engine.

 

One scenario I ran into recently was the ability to offer the fourth of the same item for free, up to a certain limit. OOTB, there are some close matches ("Get n free items of SKU x", "Get n% off item number Y of product Z"), but we want this promotion action to apply to all products in the cart, not just a specific SKU or product. Conceivably, we could add some exception products, but let's not get ahead of ourselves here.

 

This rule action would essentially look like "Get 100% off of item number 4 of the same product up to $100". If you recall, promotions in Elastic Path Commerce are comprised of Rule Elements, which are divided into three types: eligibilities (who can get the promotion), conditions (how they can get the promotion), and actions (what they get). In this case, we need to create a custom parameterized action that will capture the % off, which item to give for free, and the upper limit on the number of items to give. The customization will requires some changes to the core project (to add support for the action and a new discount type) and the Commerce Manager client (for displaying the action and allowing Marketing users to construct promotions with the new action).

 

Core Promotion Changes

Creating our new custom action consists of adding an action, which is the entity persisted in the rule element and rule parameter tables, and also the discount, which is the object that encapsulates the actual discount execution that is called by the Drools engine within the storefront when firing promotion rules during shopping cart updates. On top of this are some configuration activities that are needed to tie the custom classes into the Spring context and existing framework.

 

Adding our new action

We're creating an action called the CartNthSameProductPercentDiscountAction (sorry, not the most creative Bob Dylan-esque naming), which will extend the existing AbstractRuleAction. Our main goal here is to identify this new action as a shopping cart item discount that takes three parameters: the discount percent, which item number to discount, and the overall maximum discount amount. Most of this is mapped below. The part that is interesting is the ruleCode method, which essentially provides the Drools engine with the Drools code to be executed. The Drools code essentially delegates the call to a custom discount, which we will be adding next.

 

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/domain/rules/impl/CartNthSameProductPercentDiscountActionImpl.java

/*
 * Copyright (c) Elastic Path Software Inc., 2006
 */
package com.elasticpath.domain.rules.impl;
 
import java.math.BigDecimal;
 
import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;
import javax.persistence.Transient;
 
import org.apache.renamed.openjpa.persistence.DataCache;
 
import com.elasticpath.domain.EpDomainException;
import com.elasticpath.domain.rules.DiscountType;
import com.elasticpath.domain.rules.RuleAction;
import com.elasticpath.domain.rules.RuleElementType;
import com.elasticpath.domain.rules.RuleExceptionType;
import com.elasticpath.domain.rules.RuleParameter;
import com.elasticpath.domain.rules.RuleScenarios;
 
/**
 * Rule action that discounts the Nth product with a given UID by the given percentage.
 */
@Entity
@DiscriminatorValue("cartNthSameProductPercentDiscountAction")
@DataCache(enabled = false)
public class CartNthSameProductPercentDiscountActionImpl extends AbstractRuleActionImpl implements RuleAction {
    /**
     * Serial version id.
     */
    public static final long serialVersionUID = 5000000001L;
 
    private static final int MAX_PERCENTAGE = 100;
 
    private static final RuleElementType RULE_ELEMENT_TYPE = RuleElementType.CART_NTH_SAME_PRODUCT_PERCENT_DISCOUNT_ACTION;
 
    private static final String[] PARAMETER_KEYS = new String[] { RuleParameter.DISCOUNT_PERCENT_KEY, RuleParameter.NUM_ITEMS_KEY,
            RuleParameter.DISCOUNT_AMOUNT_KEY };
 
    /** Set of <code>RuleExcetion</code> allowed for this <code>RuleAction</code>. */
    private static final RuleExceptionType[] ALLOWED_EXCEPTIONS = new RuleExceptionType[] { };
 
    private static final DiscountType DISCOUNT_TYPE = DiscountType.CART_ITEM_DISCOUNT;
 
    /**
     * Returns the <code>RuleElementType</code> associated with this <code>RuleElement</code> subclass. The <code>RuleElementType</code>'s
     * property key must match this class' discriminator-value and the spring context bean id for this <code>RuleElement</code> implementation.
     * 
     * @return the <code>RuleElementType</code> associated with this <code>RuleElement</code> subclass.
     */
    @Override
    @Transient
    public RuleElementType getElementType() {
        return RULE_ELEMENT_TYPE;
    }
 
    /**
     * Returns the kind of this <code>RuleElement</code> (e.g. eligibility, condition, action).
     * 
     * @return the kind
     */
    @Override
    @Transient
    protected String getElementKind() {
        return ACTION_KIND;
    }
 
    /**
     * Check if this rule element is valid in the specified scenario.
     * 
     * @param scenarioId the Id of the scenario to check (defined in RuleScenarios)
     * @return true if the rule element is applicable in the given scenario
     */
    @Override
    public boolean appliesInScenario(final int scenarioId) {
        return scenarioId == RuleScenarios.CART_SCENARIO;
    }
 
    /**
     * Return the array of the allowed <code>RuleException</code> types for the rule.
     * 
     * @return an array of RuleExceptionType of the allowed <code>RuleException</code> types for the rule.
     */
    @Override
    @Transient
    public RuleExceptionType[] getAllowedExceptions() {
        return ALLOWED_EXCEPTIONS.clone();
    }
 
    /**
     * Return the Drools code corresponding to this action.
     * 
     * @return the Drools code
     * @throws EpDomainException if the rule is not well formed
     */
    @Override
    @Transient
    public String getRuleCode() throws EpDomainException {
        validate();
        StringBuffer sbf = new StringBuffer();
        sbf.append("\n\t assert(new CartNthSameProductPercentDiscountImpl(\"").append(RULE_ELEMENT_TYPE).append("\", ");
        sbf.append(this.getRuleId()).append(", \"");
        sbf.append(this.getParamValue(RuleParameter.DISCOUNT_AMOUNT_KEY));
        sbf.append("\", \"").append(this.getParamValue(RuleParameter.DISCOUNT_PERCENT_KEY)).append("\", ");
        sbf.append(this.getParamValue(RuleParameter.NUM_ITEMS_KEY));
        sbf.append(", \"").append(this.getExceptionStr()).append("\"));\n");
        return sbf.toString();
    }
 
    /**
     * Checks that the rule set domain model is well formed. For example, rule conditions must have all required parameters specified.
     * 
     * @throws EpDomainException if the structure is not correct.
     */
    @Override
    public void validate() throws EpDomainException {
        super.validate();
 
        BigDecimal discountLimit = new BigDecimal(this.getParamValue(RuleParameter.DISCOUNT_AMOUNT_KEY));
        if (discountLimit.doubleValue() <= 0) {
            throw new EpDomainException("Limit parameter: + " + discountLimit + " must be greater than zero");
        }
        
        BigDecimal discountPercent = new BigDecimal(this.getParamValue(RuleParameter.DISCOUNT_PERCENT_KEY));
        if (discountPercent.doubleValue() > MAX_PERCENTAGE || discountPercent.doubleValue() <= 0) {
            throw new EpDomainException("Invalid discount percent: " + discountPercent + ". Must be greater than 0 and no more than 100.");
        }
    }
 
    /**
     * Return an array of parameter keys required by this rule action.
     *
     * @return the parameter key array
     */
    @Override
    @Transient
    public String[] getParameterKeys() {
        return PARAMETER_KEYS.clone();
    }
 
    /**
     * Must be implemented by subclasses to return their type. Get the <code>DiscountType</code> associated with this RuleAction.
     * 
     * @return the <code>DiscountType</code> associated with this RuleAction
     */
    @Transient
    public DiscountType getDiscountType() {
        return DISCOUNT_TYPE;
    }
}

 

The RuleService needs to have the new action added to the list of valid actions as part of the Spring context.

com.elasticpath.core/WEB-INF/conf/spring/service/service.xml

    <bean id="ruleServiceLocal" class="com.elasticpath.service.rules.impl.RuleServiceImpl">

        <property name="allActions">
            <list>

                <value>cartNthSameProductPercentDiscountAction</value>

            </list>
        </property>

</bean>

 

We are adding a new persistent entity as part of the RuleElement inheritance tree, thus we need OpenJPA to enhance this entity as part of the build, as part of our persistence configuration.

com.elasticpath.core/WEB-INF/src/main/java/META-INF/persistence.xml.vm

        <class>com.elasticpath.domain.rules.impl.CartNthSameProductPercentDiscountActionImpl</class>

And if your class is not in the com.elasticpath package, don't forget to make sure the ant -enhance target (see ant/target/openjpa.xml) is properly configured to include your class.

 

Finally, Elastic Path's bean factory needs to be able to instantiate this action when required. Thus new additions to our Context constants and the bean factory is required.

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/commons/constants/ContextIdNames.java

    /** bean id for implementation of com.elasticpath.domain.rules.CartNthSameProductPercentDiscountAction. */
    public static final String CART_NTH_SAME_PRODUCT_ACTION = "cartNthSameProductPercentDiscountAction";

 

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/domain/impl/PrototypeBeanFactory.java

        addBean(ContextIdNames.CART_NTH_SAME_PRODUCT_ACTION, "com.elasticpath.domain.rules.impl.CartNthSameProductPercentDiscountActionImpl");

 

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/domain/rules/RuleElementType.java

    /**
     * Cart Nth same product percent discount action.
     */
    CART_NTH_SAME_PRODUCT_PERCENT_DISCOUNT_ACTION("cartNthSameProductPercentDiscountAction"),
    

Adding our new discount

Discounts are called by the generated Drools code via the promotion rules engine during execution time. You'll notice the constructor signature matches the Drools code generated in the above action. The guts of the doApply action simply counts up to the correct cart item to discount and applies the maximum according to the limit. Certainly not complex logic, and easily testable in an independent fashion.

 

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/domain/discounts/impl/CartNthSameProductPercentDiscountImpl.java

package com.elasticpath.domain.discounts.impl;
 
import java.math.BigDecimal;
 
import com.elasticpath.domain.discounts.DiscountItemContainer;
import com.elasticpath.domain.discounts.TotallingApplier;
import com.elasticpath.domain.shoppingcart.ShoppingItem;
 
/**
 *
 * 
 */
public class CartNthSameProductPercentDiscountImpl extends AbstractDiscountImpl {
    private final String percent;
    private final int nthItem;
    private final String limit;
 
    /**
     * @param ruleElementType rule element type.
     * @param ruleId the id of the rule executing this action
     * @param limit the maximum amount to discount
     * @param percent the percentage of the promotion X 100 (e.g. 50 means 50% off, 100 means free).
     * @param nthItem the number of items that must be present before one is discounted
     * @param exceptions exceptions to this rule element; to be used to populate the PromotionRuleExceptions.
     */
    public CartNthSameProductPercentDiscountImpl(final String ruleElementType,
            final long ruleId, final String limit, final String percent,
            final int nthItem, final String exceptions) {
        super(ruleElementType, ruleId);
        this.percent = percent;
        this.nthItem = nthItem;
        this.limit = limit;
    }
    
    /**
     * Apply discount when actuallyApply is true, and return total discount amount. 
     * @param actuallyApply true if actually apply discount.
     * @param discountItemContainer discountItemContainer that passed in. 
     * @return total discount amount of this rule action.
     */
    public BigDecimal doApply(final boolean actuallyApply, final DiscountItemContainer discountItemContainer) {
        BigDecimal discountPercent = new BigDecimal(percent);
        BigDecimal discountLimit = new BigDecimal(limit);
        
        TotallingApplier applier = getTotallingApplier(actuallyApply, 0);
        discountPercent = discountPercent.setScale(2, BigDecimal.ROUND_HALF_UP).divide(new BigDecimal(PERCENT_DIVISOR), BigDecimal.ROUND_HALF_UP);
 
        for (ShoppingItem currCartItem : discountItemContainer.getItemsLowestToHighestPrice()) {
            int itemQuantity = currCartItem.getQuantity();
            if (itemQuantity >= nthItem) {
                BigDecimal itemPrice = getItemPrice(discountItemContainer, currCartItem);
                BigDecimal discount = itemPrice.multiply(discountPercent);
                if (discount.compareTo(discountLimit) > 0) {
                    applier.apply(currCartItem, discountLimit, 1);                    
                } else {
                    applier.apply(currCartItem, discount, 1);                    
                }
                recordRuleApplied(discountItemContainer, actuallyApply, getRuleId());
            }
        }
        return applier.getTotalDiscount();
 
    }
}

 

The last piece that ties this together is to add into the Rule Set's Drools code generation an import so that our new Discount class is available in the Drools classpath.

 

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/domain/rules/impl/RuleSetImpl.java

        IMPORTS.add("com.elasticpath.domain.discounts.impl.CartNthSameProductPercentDiscountImpl");

 

 

Commerce Manager Client Changes

 

The promotion rules editor is actually quite dynamic as long as we are reusing existing parameter types. Custom parameter types that require new UI elements (like custom search finders, or special UI elements beyond the standard text fields, combo boxes and product/category pickers) are obviously going to take a bit more work than this. For our purposes, we only need to add the localizable action template text, and the editor will piece together the parameters automatically.

 

com.elasticpath.cmclient/com.elasticpath.cmclient.store/src/main/com/elasticpath/cmclient/store/promotions/PromotionsMessages.java

public static String CartNthSameProductPercentDiscountAction;

 

Our default English text, matching our proposed action.

com.elasticpath.cmclient/com.elasticpath.cmclient.store/src/main/com/elasticpath/cmclient/store/promotions/PromotionsResources.properties

CartNthSameProductPercentDiscountAction=Get [{0}]% off item number [{1}] of the same product up to $[{2}]

 

Our resulting CM client changes look like this:

screen-capture-5.png

And finally, trying this promotion out for the maximum $100 limit results in the correct cart subtotal.

screen-capture-6.png

 

I hope I've given you a good first glimpse into the promotion rule engine and how easy it is to add custom rule elements. We definitely encourage customers to think out of the box and I'd be curious to see what other extensions are out in the wild. There are some exciting enhancements to promotions coming down the pipe that should make the rule engine even more powerful, so keep tuning in for the latest and greatest.