Skip to content

Instantly share code, notes, and snippets.

@jstemerdink
Last active July 1, 2021 07:17
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save jstemerdink/9bab65e73347f7e36eeb251416013c69 to your computer and use it in GitHub Desktop.
Save jstemerdink/9bab65e73347f7e36eeb251416013c69 to your computer and use it in GitHub Desktop.

A custom promotion for giving discounts on bundle items.

Select a bundle and the items in the bundle will get the entered discount. Check "All Items Required" and the promotion will only be applied when all items are in the cart.

Read my blog here

Powered by ReSharper

namespace EPiServer.Reference.Commerce.Site.Features.Promotions
{
using System.ComponentModel.DataAnnotations;
using EPiServer.Commerce.Catalog.ContentTypes;
using EPiServer.Commerce.Marketing;
using EPiServer.Commerce.Marketing.DataAnnotations;
using EPiServer.Core;
using EPiServer.DataAnnotations;
/// <summary>
/// The <see cref="P:BuyBundleGetItemDiscount.Discount" /> will be applied to any SKUs that are part of the <see cref="P:BuyBundleGetItemDiscount.Bundle" />.
/// </summary>
[ContentType(GUID = "59631059-835F-436E-B164-AA43F31A93EF", GroupName = "entrypromotion", Order = 10900, DisplayName = "Buy bundle, get discount")]
[ImageUrl("Images/BuyFromCategoryGetItemDiscount.png")]
public class BuyBundleGetItemDiscount : EntryPromotion
{
/// <summary>
/// Gets or sets a value indicating whether [all items required].
/// </summary>
/// <value><c>true</c> if [all items required]; otherwise, <c>false</c>.</value>
[Display(Order = 20, Name = "All SKUs required to be applicable.")]
public virtual bool AllItemsRequired { get; set; }
/// <summary>
/// Gets or sets the Bundle. Any SKUs that belong to this bundle will get a discount.
/// </summary>
/// <value>The category.</value>
[AllowedTypes(typeof(BundleContent))]
[Display(Order = 10)]
[PromotionRegion("Condition")]
[UIHint("catalogentry")]
public virtual ContentReference Bundle { get; set; }
/// <summary>
/// Gets or sets the discount. The reward values that should be applied.
/// </summary>
/// <value>The discount.</value>
[Display(Order = 20)]
[PromotionRegion("Discount")]
public virtual MonetaryReward Discount { get; set; }
}
}
namespace EPiServer.Reference.Commerce.Site.Features.Promotions
{
using System;
using System.Collections.Generic;
using System.Linq;
using EPiServer.Commerce.Catalog.ContentTypes;
using EPiServer.Commerce.Catalog.Linking;
using EPiServer.Commerce.Marketing;
using EPiServer.Commerce.Marketing.Promotions;
using EPiServer.Commerce.Order;
using EPiServer.Commerce.Validation;
using EPiServer.Core;
using EPiServer.Framework.Localization;
using EPiServer.ServiceLocation;
/// <summary>
/// Class BuyBundleGetItemDiscountProcessor.
/// </summary>
/// <seealso cref="EPiServer.Commerce.Marketing.Promotions.GetItemDiscountProcessorBase{BuyBundleGetItemDiscount}" />
[ServiceConfiguration(Lifecycle = ServiceInstanceScope.Singleton)]
public class BuyBundleGetItemDiscountProcessor : GetItemDiscountProcessorBase<BuyBundleGetItemDiscount>
{
/// <summary>
/// The collection target evaluator
/// </summary>
private readonly CollectionTargetEvaluator collectionTargetEvaluator;
/// <summary>
/// The content loader
/// </summary>
private readonly IContentLoader contentLoader;
/// <summary>
/// Initializes a new instance of the <see cref="BuyBundleGetItemDiscountProcessor" /> class.
/// </summary>
/// <param name="collectionTargetEvaluator">The service that is used to evaluate the target properties.</param>
/// <param name="localizationService">The service that is used to handle localization.</param>
/// <param name="contentLoader">The content loader.</param>
public BuyBundleGetItemDiscountProcessor(
CollectionTargetEvaluator collectionTargetEvaluator,
LocalizationService localizationService,
IContentLoader contentLoader)
: base(
targetEvaluator: collectionTargetEvaluator,
localizationService: localizationService,
targetGetter: GetTargetItems,
discountGetter: x => x.Discount)
{
ParameterValidator.ThrowIfNull(() => collectionTargetEvaluator, value: collectionTargetEvaluator);
this.collectionTargetEvaluator = collectionTargetEvaluator;
this.contentLoader = contentLoader;
}
/// <summary>
/// Determines whether [contains all items] [the specified a].
/// </summary>
/// <param name="lineItemCodes">The line item codes.</param>
/// <param name="targetCodes">The target codes.</param>
/// <returns><c>true</c> if [contains all items] [the specified a]; otherwise, <c>false</c>.</returns>
protected static bool ContainsAllItems(IEnumerable<string> lineItemCodes, IEnumerable<string> targetCodes)
{
return !targetCodes.Except(second: lineItemCodes).Any();
}
/// <summary>
/// Gets the target items.
/// </summary>
/// <param name="promotionData">The promotion data.</param>
/// <returns>The DiscountItems.</returns>
protected static DiscountItems GetTargetItems(BuyBundleGetItemDiscount promotionData)
{
IEnumerable<ContentReference> entries = ListBundleEntries(referenceToBundle: promotionData.Bundle)
.Select(e => e.Child);
return new DiscountItems { Items = entries.ToList(), MatchRecursive = false };
}
/// <summary>
/// Lists the bundle entries.
/// </summary>
/// <param name="referenceToBundle">The reference to bundle.</param>
/// <returns>A list of <see cref="BundleEntry"/>.</returns>
protected static IEnumerable<BundleEntry> ListBundleEntries(ContentReference referenceToBundle)
{
IRelationRepository relationRepository = ServiceLocator.Current.GetInstance<IRelationRepository>();
// Relations to bundle entries are of type BundleEntry
List<BundleEntry> bundleEntries = relationRepository.GetChildren<BundleEntry>(parentLink: referenceToBundle)
.ToList();
return bundleEntries;
}
/// <summary>
/// Verify that the current promotion can potentially be fulfilled
/// </summary>
/// <param name="promotionData">The promotion to evaluate.</param>
/// <param name="context">The context for the promotion processor evaluation.</param>
/// <returns><c>true</c> if the current promotion can potentially be fulfilled; otherwise, <c>false</c>.</returns>
/// <remarks>This method is intended to be a very quick pre-check to avoid doing more expensive operations.
/// Used to verify basic things, for example a Buy-3-pay-for-2 promotion needs at least three items in the cart.
/// If we have less than three we can skip further processing.</remarks>
protected override bool CanBeFulfilled(
BuyBundleGetItemDiscount promotionData,
PromotionProcessorContext context)
{
if (base.CanBeFulfilled(promotion: promotionData, context: context))
{
return !ContentReference.IsNullOrEmpty(contentLink: promotionData.Bundle);
}
return false;
}
/// <summary>
/// Implements promotion specific logic for determining the fulfillment status of the promotion.
/// </summary>
/// <param name="promotionData">The promotion data.</param>
/// <param name="context">The context.</param>
/// <returns>The calculated fulfillment status as a <see cref="T:EPiServer.Commerce.Marketing.FulfillmentStatus" /> value.</returns>
protected override FulfillmentStatus GetFulfillmentStatus(
BuyBundleGetItemDiscount promotionData,
PromotionProcessorContext context)
{
IEnumerable<ILineItem> lineItems = context.OrderForm.GetAllLineItems().Where(item => !item.IsGift);
IEnumerable<ContentReference> targets = ListBundleEntries(referenceToBundle: promotionData.Bundle)
.Select(e => e.Child);
IList<string> applicableCodes;
if (promotionData.AllItemsRequired)
{
// If all SKUs are required to trigger the promotion, the applicable codes shoud not be filtered by the CollectionTargetEvaluator
applicableCodes = targets
.Select(
contentReference => this.contentLoader.Get<EntryContentBase>(contentLink: contentReference)
?.Code).Where(code => !string.IsNullOrWhiteSpace(value: code)).ToList();
}
else
{
applicableCodes = this.collectionTargetEvaluator.GetApplicableCodes(
lineItemsInOrder: lineItems,
targets: targets,
matchRecursive: false);
}
return this.GetStatusForBuyBundlePromotion(
codes: applicableCodes,
lineItems: lineItems,
matchAllItems: promotionData.AllItemsRequired);
}
/// <summary>
/// Gets information about the settings for a specific instance of a promotion type.
/// Used when displaying promotion information to a site visitor/shopper.
/// </summary>
/// <param name="promotionData">The promotion data to get items for.</param>
/// <returns>The promotion condition and reward items.</returns>
/// <remarks><para>
/// This method is intended to be used on a site to display information about a promotion to a visitor/shopper.
/// </para>
/// <para>
/// It is never used during the evaluation of the promotion, it only exists to provide information about the settings for this instance of a promotion type.
/// So a use case for this could be that you have a "Buy 3 get the cheapest for one for free" promotion. And you want to display information to the visitor/shopper
/// that "If you buy three items from the category cooking books, you will get the cheapest one for free".
/// </para>
/// <para>
/// This method should not be called explicitly from the site code, but will be called from the IPromotionEngine extension method GetPromotionItemsForCampaign.
/// </para></remarks>
protected override PromotionItems GetPromotionItems(BuyBundleGetItemDiscount promotionData)
{
IEnumerable<ContentReference> entries = ListBundleEntries(referenceToBundle: promotionData.Bundle)
.Select(e => e.Child);
CatalogItemSelection catalogItemSelection = new CatalogItemSelection(
items: entries,
type: CatalogItemSelectionType.Specific,
includesSubcategories: false);
return new PromotionItems(
promotion: promotionData,
condition: catalogItemSelection,
reward: catalogItemSelection);
}
/// <summary>
/// Gets all <see cref="T:EPiServer.Commerce.Marketing.AffectedEntries" />s affected by a given promotion.
/// </summary>
/// <param name="promotionData">The promotion used to evaluate the product codes.</param>
/// <param name="context">The context for the promotion processor evaluation.</param>
/// <param name="applicableCodes">A collection of product codes to be checked against a promotion.</param>
/// <returns>A list of applicable <see cref="T:EPiServer.Commerce.Marketing.RedemptionDescription" />s</returns>
protected override IEnumerable<RedemptionDescription> GetRedemptions(
BuyBundleGetItemDiscount promotionData,
PromotionProcessorContext context,
IEnumerable<string> applicableCodes)
{
List<RedemptionDescription> redemptionDescriptionList = new List<RedemptionDescription>();
decimal val2 = this.GetLineItems(orderForm: context.OrderForm)
.Where(li => applicableCodes.Contains(value: li.Code)).Sum(li => li.Quantity);
decimal num = Math.Min(this.GetMaxRedemptions(redemptions: promotionData.RedemptionLimits), val2: val2);
for (int index = 0; index < num; ++index)
{
AffectedEntries entries =
context.EntryPrices.ExtractEntries(codes: applicableCodes, quantity: decimal.One);
if (entries != null)
{
redemptionDescriptionList.Add(this.CreateRedemptionDescription(affectedEntries: entries));
}
}
return redemptionDescriptionList;
}
/// <summary>
/// Gets the fulfillment status using <paramref name="codes" /> for Buy from category get item discount promotion.
/// </summary>
/// <param name="codes">The codes for eligible products.</param>
/// <param name="lineItems">The line items in current order form.</param>
/// <param name="matchAllItems">if set to <c>true</c> [match all items].</param>
/// <returns>The fulfillment status.</returns>
protected FulfillmentStatus GetStatusForBuyBundlePromotion(
IEnumerable<string> codes,
IEnumerable<ILineItem> lineItems,
bool matchAllItems)
{
if (matchAllItems)
{
return !ContainsAllItems(lineItems.Select(l => l.Code), targetCodes: codes)
? FulfillmentStatus.NotFulfilled
: FulfillmentStatus.Fulfilled;
}
return !lineItems.Any(li => codes.Contains(value: li.Code))
? FulfillmentStatus.NotFulfilled
: FulfillmentStatus.Fulfilled;
}
}
}
namespace EPiServer.Reference.Commerce.Site.Features.Promotions
{
using System.Collections.Generic;
using EPiServer.Commerce.Validation;
using EPiServer.Core;
using EPiServer.Framework.Localization;
using EPiServer.Validation;
/// <summary>
/// Class BuyBundleGetItemDiscountValidator.
/// </summary>
/// <seealso cref="EPiServer.Commerce.Validation.PromotionDataValidatorBase{BuyBundleGetItemDiscount}" />
public class BuyBundleGetItemDiscountValidator : PromotionDataValidatorBase<BuyBundleGetItemDiscount>
{
/// <summary>
/// Initializes a new instance of the <see cref="BuyBundleGetItemDiscountValidator"/> class.
/// </summary>
/// <param name="localizationService">
/// The localization service.
/// </param>
public BuyBundleGetItemDiscountValidator(LocalizationService localizationService)
: base(localizationService: localizationService)
{
}
/// <summary>
/// Adds the errors if needed.
/// </summary>
/// <param name="promotionData">The promotion data.</param>
/// <param name="validationErrors">The validation errors.</param>
protected override void AddErrorsIfNeeded(
BuyBundleGetItemDiscount promotionData,
List<ValidationError> validationErrors)
{
if (!ContentReference.IsNullOrEmpty(contentLink: promotionData.Bundle))
{
return;
}
List<ValidationError> validationErrorList = validationErrors;
ValidationError validationError = new ValidationError();
validationError.Severity = ValidationErrorSeverity.Error;
validationError.ValidationType = ValidationErrorType.StorageValidation;
validationError.PropertyName = "Bundle";
string str = this.LocalizationService.GetString("/commerce/validation/buyfrombundlerequired");
validationError.ErrorMessage = str;
validationErrorList.Add(item: validationError);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment