-
-
Notifications
You must be signed in to change notification settings - Fork 1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Better Promotions Free Gift support #1798
Comments
In our case (online supermarket), this is a very common promotion. Here's the ideal implementation:
Here's how we solve the auto add / remove: @VendurePlugin({
imports: [PluginCommonModule],
entities: [],
providers: [PromoService],
})
export class PromoPlugin implements OnApplicationBootstrap {
constructor(private orderService: OrderService, private promoService: PromoService) {}
async onApplicationBootstrap() {
const addItemToOrder = this.orderService.addItemToOrder;
const adjustOrderLine = this.orderService.adjustOrderLine;
const removeItemFromOrder = this.orderService.removeItemFromOrder;
// TODO: Prevent adding of hidden items
this.orderService.addItemToOrder = async (...args: Parameters<typeof addItemToOrder>) => {
let order = await addItemToOrder.apply(this.orderService, args);
if (order instanceof Order) {
order = await this.promoService.autoAddPromoItems(args[0], addItemToOrder, adjustOrderLine, order);
}
if (order instanceof Order) {
order.lines = order.lines.sort((a, b) => +a.createdAt - +b.createdAt);
}
return order;
};
// TODO: Prevent adding of hidden items
this.orderService.adjustOrderLine = async (...args: Parameters<typeof adjustOrderLine>) => {
let order = await adjustOrderLine.apply(this.orderService, args);
if (order instanceof Order) {
order = await this.promoService.autoAddPromoItems(args[0], addItemToOrder, adjustOrderLine, order);
}
if (order instanceof Order) {
order.lines = order.lines.sort((a, b) => +a.createdAt - +b.createdAt);
}
return order;
};
this.orderService.removeItemFromOrder = async (...args: Parameters<typeof removeItemFromOrder>) => {
let order = await removeItemFromOrder.apply(this.orderService, args);
if (order instanceof Order) {
order = await this.promoService.autoAddPromoItems(args[0], addItemToOrder, adjustOrderLine, order);
}
if (order instanceof Order) {
order.lines = order.lines.sort((a, b) => +a.createdAt - +b.createdAt);
}
return order;
};
}
} Here's the autoAdd method. As you can see it depends directly on having specific conditions and actions active. @Injectable()
export class PromoService {
constructor(
private orderService: OrderService,
private promotionService: PromotionService,
private conn: TransactionalConnection,
) {}
async autoAddPromoItems(
ctx: RequestContext,
addItemToOrderFn: typeof this.orderService.addItemToOrder,
adjustOrderLineFn: typeof this.orderService.adjustOrderLine,
order: Order,
) {
const { items: promotions } = await this.promotionService.findAll(ctx, {
filter: { enabled: { eq: true } },
sort: { priorityScore: 'ASC' },
});
// Tracks which variantIds should be added or removed
const updates: { [key: string]: number } = {};
for (const promotion of promotions) {
const condition = promotion.conditions.find(c => c.code === 'buy_x_of_y');
const action = promotion.actions.find(a => a.code === 'get_x_free');
if (!condition || !action) {
// Auto-adding and removing only works for BOGO-type promotions
continue;
}
const { amount, variantIds, autoAdd } = getXFreeArgParser(action.args);
if (!autoAdd) {
// No need to adjust quantities without autoadd
continue;
}
const state = await promotion.test(ctx, order);
if (!state || typeof state !== 'object') {
continue;
}
const appliedDiscounts = state.buy_x_of_y.discounts as number;
const purchasedVaiants = state.buy_x_of_y.variants as { [key: string]: number };
const totalQuantity = appliedDiscounts * amount;
for (const variantId of variantIds) {
const line = order.lines.find(line => line.productVariant.id === variantId);
// For BOGO discounts (buy 1 milk, get 1 milk free) we do not auto add
// It causes a weird interaction where adding 1 beer adds 2.
// We solve this by making another SKU for the same product and make it hidden.
if ((totalQuantity === 0 && !line) || purchasedVaiants[variantId]) {
continue;
}
updates[variantId] = (updates[variantId] || 0) + totalQuantity;
}
}
const variantIds = Object.keys(updates);
const hidden =
variantIds.length === 0
? []
: await this.conn
.getRepository(ctx, Product)
.createQueryBuilder('p')
.select('p.customFieldsHidden', 'h')
.addSelect('pv.id', 'id')
.innerJoin(ProductVariant, 'pv', 'pv.productId = p.id AND pv.id IN (:...variantIds)', { variantIds })
.getRawMany();
for (const variantId of variantIds) {
const line = order.lines.find(line => line.productVariant.id === variantId);
const newQuantity = updates[variantId];
const delta = newQuantity - (line?.quantity || 0);
const autoRemove = !!hidden.find(l => l.id === variantId);
let result: any;
if (line && (delta > 0 || (autoRemove && delta < 0))) {
result = await adjustOrderLineFn.call(this.orderService, ctx, order.id, line.id, newQuantity);
} else if (!line && delta > 0) {
result = await addItemToOrderFn.call(this.orderService, ctx, order.id, variantId, newQuantity);
}
if (result instanceof Order) {
order = result; // Small optimization to prvent another fetch
}
}
return await this.orderService.applyPriceAdjustments(ctx, order);
}
} And here's the implementation of some promotion actions and conditions: import compact from 'lodash/compact';
import { PromotionCondition, LanguageCode, FacetValueChecker, TransactionalConnection, PromotionItemAction } from '@vendure/core';
let facetValueChecker: FacetValueChecker;
export const buyXofY = new PromotionCondition({
code: 'buy_x_of_y',
description: [
{
languageCode: LanguageCode.en,
value: 'Buy items/value of variants/facets',
},
],
args: {
amount: {
type: 'int',
defaultValue: 0,
required: false,
label: [{ languageCode: LanguageCode.en, value: 'At least this many items (0 to ignore)' }],
},
value: {
type: 'int',
defaultValue: 0,
required: false,
ui: { component: 'currency-form-input' },
label: [{ languageCode: LanguageCode.en, value: 'At least this much in value (0 to ignore)' }],
},
variantIds: {
type: 'ID',
list: true,
required: false,
ui: { component: 'product-selector-form-input' },
label: [{ languageCode: LanguageCode.en, value: 'Specific variants' }],
},
facetIds: {
type: 'ID',
list: true,
required: false,
ui: { component: 'facet-value-form-input' },
label: [{ languageCode: LanguageCode.en, value: 'Facet values' }],
},
},
init(injector) {
facetValueChecker = new FacetValueChecker(injector.get(TransactionalConnection));
},
async check(ctx, order, args) {
if (!order || !order.lines) {
return false;
}
// All order lines that contain
const lines = compact(
await Promise.all(
order.lines.map(async line => {
const hasVariant = args.variantIds.length > 0 && args.variantIds.includes(line.productVariant.id);
const hasFacets = args.facetIds.length > 0 && (await facetValueChecker.hasFacetValues(line, args.facetIds));
return (hasVariant || hasFacets) && line;
}),
),
);
const { quantity, value } = lines.reduce(
(acc, line) => {
acc.value += ctx.channel.pricesIncludeTax ? line.linePriceWithTax : line.linePrice;
acc.quantity += line.quantity;
return acc;
},
{ quantity: 0, value: 0 },
);
const discountsToApplyBasedOnQuantity = args.amount && Math.floor(quantity / args.amount);
const discountsToApplyBasedOnValue = args.value && Math.floor(value / args.value);
return {
triggerQuantity: args.amount || 0,
variants: Object.fromEntries(lines.map(line => [line.productVariant.id, line.quantity])),
discounts: Math.max(discountsToApplyBasedOnQuantity, discountsToApplyBasedOnValue),
};
},
});
export const getXFree = new PromotionItemAction({
code: 'get_x_free',
description: [
{
languageCode: LanguageCode.en,
value: 'Get a number of selected variants for free',
},
],
args: {
amount: {
type: 'int',
defaultValue: 1,
label: [{ languageCode: LanguageCode.en, value: 'Free amount of EACH selected variant' }],
},
autoAdd: {
type: 'boolean',
defaultValue: false,
label: [{ languageCode: LanguageCode.en, value: 'Auto-add to cart' }],
},
autoRemove: {
type: 'boolean',
defaultValue: false,
label: [{ languageCode: LanguageCode.en, value: 'No regular purchase (only select for hidden items)' }],
},
variantIds: {
type: 'ID',
list: true,
ui: { component: 'product-selector-form-input' },
label: [{ languageCode: LanguageCode.en, value: 'Product Variants' }],
},
},
conditions: [buyXofY],
execute(_ctx, item, line, args, state) {
if (!state || !line) {
return 0;
}
const { discounts, variants, triggerQuantity } = state.buy_x_of_y;
let totalDiscountedItems = discounts * args.amount;
if (totalDiscountedItems > 0 && args.variantIds.includes(line.productVariant.id)) {
const itemIndex = line.items.findIndex(i => i.id === item.id);
// Number of already purchased items OF THE SAME variant that is discounted
// This is important for BOGO discount (buy 1 milk, get 1 milk free)
// We don't want to Apply a "GET 1 FREE" discount to the only item in the cart
const purchasedVariants = variants[line.productVariant.id] || 0;
if (purchasedVariants && triggerQuantity) {
// The number of items including the free ones. Eg for "Buy 2 Get 1 free" it's 3.
// This doesn't work with "Buy 3 Get 2 free" entirely, because you need at least 5 items
// but you really should get a discount on "Buy 3 get 1 free".
const discountPackSize = triggerQuantity + args.amount;
totalDiscountedItems = 0;
let quant = line.quantity;
while (true) {
if (quant >= discountPackSize) {
quant -= discountPackSize;
totalDiscountedItems += args.amount;
continue;
}
if (quant > triggerQuantity) {
const diff = quant - triggerQuantity;
quant -= diff;
totalDiscountedItems += diff;
}
break;
}
}
// This method gets called once per OrderItem
// This is how we decide if we've discounted enough items
if (itemIndex < totalDiscountedItems) {
return -item.listPrice;
}
}
return 0;
},
}); |
Also, if you're touching the promotions, you might wanna look into allowing promotion actions do other stuff than applying a discount. For example, I'm working on a loyalty point scheme right now, and we want certain products instead of being discounted, to add loyalty points upon successful order payment. To do this via promotions, I need:
For example, you might extend the promotion action interface with 2 methods:
If these are defined, then you can execute them instead of |
@skid thanks for all this very valuable input! I've thought a bit about this "onValid, onInvalid" API you suggest. The basic concept I think this captures is that of side effects. So right now, the But we could allow an optional side-effect API which is along the lines of your suggestion. I'll explore some designs along these lines. |
How to determine how gifts should be stored in the Product list? as standard product item? or special "gift" tagged products? |
another question , if |
Technically, since the Point is - if you want to allow rich extensibility to Vendure - you can't do that just with pure functions. Pure functions require that the entire model is known at design time. |
You can set a facet to the product and filter by that facet in the promotion condition. |
Yes, sure you can already do side-effects, but I really mean the intention is that it is pure, and then we can have an explicit API for side-effects only (i.e. no return value).
I think this will need to be solved in the inplementation of the PromotionAction itself, but another way would be to just look up the variant ID, since we should already know the ID of the variant being added as a free gift from the |
OK I have a promising proof-of-concept design running locally. Here's what a free gift promotion looks like: let orderService: OrderService;
export const freeGiftAction = new PromotionItemAction({
code: 'free_gift',
description: [{ languageCode: LanguageCode.en, value: 'Add free gifts to the order' }],
args: {
productVariantIds: {
type: 'ID',
list: true,
ui: { component: 'product-selector-form-input' },
label: [{ languageCode: LanguageCode.en, value: 'Gift product variants' }],
},
},
init(injector) {
orderService = injector.get(OrderService);
},
execute(ctx, orderItem, orderLine, args) {
if (lineContainsIds(args.productVariantIds, orderLine)) {
const unitPrice = ctx.channel.pricesIncludeTax ? orderLine.unitPriceWithTax : orderLine.unitPrice;
return -unitPrice;
}
return 0;
},
async onActivate(ctx, order, args) {
for (const id of args.productVariantIds) {
if (!order.lines.find(line => idsAreEqual(line.productVariant.id, id))) {
// The order does not yet contain this free gift, so add it
await orderService.addItemToOrder(ctx, order.id, id, 1);
}
}
},
async onDeactivate(ctx, order, args) {
for (const id of args.productVariantIds) {
const lineWithFreeGift = order.lines.find(line => idsAreEqual(line.productVariant.id, id));
if (lineWithFreeGift) {
// Remove the free gift
await orderService.adjustOrderLine(
ctx,
order.id,
lineWithFreeGift.id,
lineWithFreeGift.quantity - 1,
);
}
}
},
}); Going to do some more testing and make sure this is not interfering with any existing processes. Note that in order for this API to work, I needed to make a functional change:
I don't think this should be a breaking change, but who knows whether someone for some reason relied on the former behaviour 🤷 . I cannot remember the reasoning for only adding the relation upon order completion, but in any case it seems better to add the relation as soon as a Promotion activates. |
when |
and then could add associate |
Example:
You mean this issue? yes I will probably be able to add that. |
yes, :). adjustOrderline will invoke onActivate & onDeactivate at the same time? |
What do you mean by "upon order completion" ? Actually - I'm not quite sure what populating the relation means. addItemToOrder Any one of these can validate or invalidate a promotion. Another caveat with free items is that the user might manually remove an automatically added free item, but if the promotion is still valid, it would just add it back leading to a confusing UX. The way we solve this is by adding a custom flag to free items which the frontend uses to disable the add/remove to cart buttons on that specific product. There is no straightforward solution to this I think without tracking the user's actions, but even then - the behaviour is not easy to define from a product aspect. |
I'm referring to this: https://github.com/vendure-ecommerce/vendure/blob/master/packages/core/src/service/helpers/order-state-machine/order-state-machine.ts#L187 Namely, until the order is placed (transitions to PaymentAuthorized/PaymentSettled by default), the In my POC implementation, I am doing the side effect checks in the
This is a very good point, and can be accommodated with this POC design - you'd need to add the customField to the OrderLine and set it in the onActivate |
it seems that |
for above sample, gift product need to be remove/added should be determined what my shopping cart have rather than we are in |
I'm not sure about what difference this makes? Is there some capability which would be possible with a single handler rather than 2? I tend to like 2 explicitly-named handlers better.
In the current POC design, |
yes agree, However, I still don't understand when |
The flow is like this: 1 .order change (add item, change quantity, apply coupon etc) I've just published this POC on the major branch: 1a4a117 And released it in pre-release v2.0.0-next.18, so I can do some real-world testing with it. |
First feedback from testing:
I think we can handle this by wrapping the calls to |
the flow very clearly: 1a4a117 :) |
Is your feature request related to a problem? Please describe.
It is quite common to have a promotion that entitles the customer to a free gift. Currently we are able to support making an item free using an PromotionItemAction, but the customer still has to manually add it to the order.
The ideal flow in this case however is that once the condition(s) pass, then Vendure can automatically add the free gift to the order.
To work around this, I have suggested in the past the creation of a custom
addItemToOrder
mutation which contains logic to achieve this. However, it would be better to support this common use-case natively.Describe the solution you'd like
Perhaps a new kind of PromotionAction which is dedicated to adding items the order. The exact mechanism of how this would actually work is not yet clear.
Currently all promotion actions are processed as part of the
OrderCalculator.applyPriceAdjustments()
method. I would not suggest using this same method to add a new item, since this is mixing concerns and also you get this recursive issue where adding a new item during the price calculation can then potentially trigger new promotions, potentially changing prices of those already added etc.It would probably work in a separate stage, so:
Open questions
The text was updated successfully, but these errors were encountered: