Closed michaelbromley closed 2 years ago
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:
onValid: async (ctx, order, item) => void,
onInvalid: async (ctx, order, item) => void,
If these are defined, then you can execute them instead of exec
.
@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 execute
functions are pure and simply return a number representing the amount by which to discount. That's a nice simple API IMO and I want to keep that.
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?
how to determine when a special rule matched, which gift
should be auto
added?
another question , if promotionactions
been applied event if return 0
the order.discounts always will list this discount item, It's a little bit confusing i think
The basic concept I think this captures is that of side effects. So right now, the
execute
functions are pure and simply return a number representing the amount by which to discount. That's a nice simple API IMO and I want to keep that.
Technically, since the execute
method is async, you can also use it to do side-effects. For example, right now I'm thinking of using it to set a custom order field called awardedLoyaltyPoints
and return 0. Upon transitioning to PaymentSettled
I plan to use that field to award points to the customer. So, I'm using it to do a side-effect because the alternative would be to build my own system of configurable operations and promo condition evaluation, which is an even worse idea :)
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.
How to determine how gifts should be stored in the Product list? as standard product item? or special "gift" tagged products? how to determine when a special rule matched, which
gift
should beauto
added?
You can set a facet to the product and filter by that facet in the promotion condition.
Technically, since the execute method is async, you can also use it to do side-effects.
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).
How to determine how gifts should be stored in the Product list? as standard product item? or special "gift" tagged products? how to determine when a special rule matched, which gift should be auto added?
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 args
object.
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:
Order.promotions
relation would only get populated upon order completion (checkout).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 onDeactivate
will be triggered?
and then could add associate Promotion
as parameter for PromotionAction
?
when onDeactivate will be triggered? It gets triggered whenever a promotion which was active, becomes no longer active.
Example:
onActivate
is invokedonDeactivate
is invoked.and then could add associate Promotion as parameter for PromotionAction?
You mean this issue?
yes I will probably be able to add that.
yes, :). adjustOrderline will invoke onActivate & onDeactivate at the same time?
Previously, the Order.promotions relation would only get populated upon order completion (checkout).
What do you mean by "upon order completion" ? Actually - I'm not quite sure what populating the relation means. I think the important thing is to make the onActivate and onDeactivate checks run whenever the order changes, namely the following methods:
addItemToOrder adjustOrderLine removeItemFromOrder applyCouponCode removeCouponCode addSurchargeToOrder removeSurchargeFromOrder
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.
What do you mean by "upon order completion" ?
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 order.promotions
array will always be empty, because no Promotions have been related to the order yet. Under the existing system, this relation is only established (i.e. order.promotions = activePromotions, save order
) upon placing the order.
In my POC implementation, I am doing the side effect checks in the OrderService.applyPriceAdjustments()
method, which I believe is called by all of the above.
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.
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 addItemToOrder
call.
it seems that onActivate
, onDeActivate
it seems it hard to tell what phase of this two states , why not conbine these to one event
like onChange
? becase in facet for onActive, onDeactive we always need to throught all order information to determine the workflow
for above sample, gift product need to be remove/added should be determined what my shopping cart have rather than we are in onActivate
or onDeActivate
phase, BTW should we make sure that onActive
, onDeatvie
always exec completed before execute()
method invoke?
it seems that onActivate, onDeActivate it seems it hard to tell what phase of this two states , why not conbine these to one event like onChange?
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.
BTW should we make sure that onActive, onDeatvie always exec completed before execute() method invoke?
In the current POC design, onActivated
and onDeactivated
are always called after all order price calculations (including all execute
functions) have completed.
yes agree, However, I still don't understand when onActive
should occur, when onDeactive
is triggered, and how these two methods are associated with the execution order of PromotionAction
, And what kind of side effect logic should I do in these two methods
The flow is like this:
1 .order change (add item, change quantity, apply coupon etc)
OrderService.applyPriceAdjustments()
, invoke all execute()
functions on PromotionActions where the conditions are satisfied.onActivated
onDeactivated
.I've just published this POC on the major branch: https://github.com/vendure-ecommerce/vendure/commit/1a4a117f6fca946dfa4999bad194c28c97f865eb
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:
orderService.addItemToOrder()
call was returning an ErrorResult.I think we can handle this by wrapping the calls to onActivate
and onDeactivate
in a try-catch, and in the case of an error, we convert that into a new type of ErrorResult which we'll have to add to all the union types that can trigger a promotion side effect.
the flow very clearly: https://github.com/vendure-ecommerce/vendure/commit/1a4a117f6fca946dfa4999bad194c28c97f865eb
:)
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