Skip to content

Commit

Permalink
feat(core): Add support for PromotionAction side effects
Browse files Browse the repository at this point in the history
Relates to #1798
  • Loading branch information
michaelbromley committed Sep 29, 2022
1 parent 2b1d6b5 commit 1a4a117
Show file tree
Hide file tree
Showing 8 changed files with 177 additions and 44 deletions.
13 changes: 6 additions & 7 deletions packages/core/e2e/order-promotion.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,8 @@ describe('Promotions applied to Orders', () => {
});

it('containsProducts', async () => {
const item5000 = getVariantBySlug('item-5000')!;
const item1000 = getVariantBySlug('item-1000')!;
const promotion = await createPromotion({
enabled: true,
name: 'Free if buying 3 or more offer products',
Expand All @@ -486,25 +488,22 @@ describe('Promotions applied to Orders', () => {
{ name: 'minimum', value: '3' },
{
name: 'productVariantIds',
value: JSON.stringify([
getVariantBySlug('item-5000').id,
getVariantBySlug('item-1000').id,
]),
value: JSON.stringify([item5000.id, item1000.id]),
},
],
},
],
actions: [freeOrderAction],
});
await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
productVariantId: getVariantBySlug('item-5000').id,
productVariantId: item5000.id,
quantity: 1,
});
const { addItemToOrder } = await shopClient.query<
AddItemToOrder.Mutation,
AddItemToOrder.Variables
>(ADD_ITEM_TO_ORDER, {
productVariantId: getVariantBySlug('item-1000').id,
productVariantId: item1000.id,
quantity: 1,
});
orderResultGuard.assertSuccess(addItemToOrder);
Expand All @@ -515,7 +514,7 @@ describe('Promotions applied to Orders', () => {
AdjustItemQuantity.Mutation,
AdjustItemQuantity.Variables
>(ADJUST_ITEM_QUANTITY, {
orderLineId: addItemToOrder!.lines[0].id,
orderLineId: addItemToOrder!.lines.find(l => l.productVariant.id === item5000.id)!.id,
quantity: 2,
});
orderResultGuard.assertSuccess(adjustOrderLine);
Expand Down
91 changes: 82 additions & 9 deletions packages/core/src/config/promotion/promotion-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ type TupleToUnion<T extends any[]> = T[number];
*/
export type ConditionState<
U extends Array<PromotionCondition<any>>,
T extends [string, any] = TupleToUnion<CodesStateTuple<ConditionTuple<U>>>
T extends [string, any] = TupleToUnion<CodesStateTuple<ConditionTuple<U>>>,
> = { [key in T[0]]: Extract<T, [key, any]>[1] };

/**
Expand Down Expand Up @@ -103,7 +103,7 @@ export type ExecutePromotionOrderActionFn<T extends ConfigArgs, U extends Array<
*/
export type ExecutePromotionShippingActionFn<
T extends ConfigArgs,
U extends Array<PromotionCondition<any>>
U extends Array<PromotionCondition<any>>,
> = (
ctx: RequestContext,
shippingLine: ShippingLine,
Expand All @@ -112,6 +112,21 @@ export type ExecutePromotionShippingActionFn<
state: ConditionState<U>,
) => number | Promise<number>;

/**
* @description
* The signature of a PromotionAction's side-effect functions `onActivate` and `onDeactivate`.
*
* @docsCategory promotions
* @docsPage promotion-action
* @since 1.8.0
* @experimental
*/
type PromotionActionSideEffectFn<T extends ConfigArgs> = (
ctx: RequestContext,
order: Order,
args: ConfigArgValues<T>,
) => void | Promise<void>;

/**
* @description
* Configuration for all types of {@link PromotionAction}.
Expand All @@ -121,7 +136,7 @@ export type ExecutePromotionShippingActionFn<
*/
export interface PromotionActionConfig<
T extends ConfigArgs,
U extends Array<PromotionCondition<any>> | undefined
U extends Array<PromotionCondition<any>> | undefined,
> extends ConfigurableOperationDefOptions<T> {
/**
* @description
Expand All @@ -142,6 +157,28 @@ export interface PromotionActionConfig<
* the return values of the PromotionConditions' `check()` function.
*/
conditions?: U extends undefined ? undefined : ConditionTuple<Exclude<U, undefined>>;
/**
* @description
* An optional side effect function which is invoked when the promotion
* becomes active. It can be used for things like adding a free gift to the order
* or other side effects that are unrelated to price calculations.
*
* If used, make sure to use the corresponding `onDeactivate` function to clean up
* or reverse any side effects as needed.
*
* @since 1.8.0
* @experimental
*/
onActivate?: PromotionActionSideEffectFn<T>;

/**
* @description
* Used to reverse or clean up any side effects executed as part of the `onActivate` function.
*
* @since 1.8.0
* @experimental
*/
onDeactivate?: PromotionActionSideEffectFn<T>;
}

/**
Expand All @@ -156,6 +193,8 @@ export interface PromotionItemActionConfig<T extends ConfigArgs, U extends Promo
/**
* @description
* The function which contains the promotion calculation logic.
* Should resolve to a number which represents the amount by which to discount
* the OrderItem, i.e. the number should be negative.
*/
execute: ExecutePromotionItemActionFn<T, U>;
}
Expand All @@ -171,6 +210,8 @@ export interface PromotionOrderActionConfig<T extends ConfigArgs, U extends Prom
/**
* @description
* The function which contains the promotion calculation logic.
* Should resolve to a number which represents the amount by which to discount
* the Order, i.e. the number should be negative.
*/
execute: ExecutePromotionOrderActionFn<T, U>;
}
Expand All @@ -186,6 +227,8 @@ export interface PromotionShippingActionConfig<T extends ConfigArgs, U extends P
/**
* @description
* The function which contains the promotion calculation logic.
* Should resolve to a number which represents the amount by which to discount
* the Shipping, i.e. the number should be negative.
*/
execute: ExecutePromotionShippingActionFn<T, U>;
}
Expand All @@ -201,7 +244,7 @@ export interface PromotionShippingActionConfig<T extends ConfigArgs, U extends P
*/
export abstract class PromotionAction<
T extends ConfigArgs = {},
U extends PromotionCondition[] | undefined = any
U extends PromotionCondition[] | undefined = any,
> extends ConfigurableOperationDef<T> {
/**
* @description
Expand All @@ -212,12 +255,32 @@ export abstract class PromotionAction<
* @default 0
*/
readonly priorityValue: number;
/** @internal */
readonly conditions?: U;
/** @internal */
protected readonly onActivateFn?: PromotionActionSideEffectFn<T>;
/** @internal */
protected readonly onDeactivateFn?: PromotionActionSideEffectFn<T>;

protected constructor(config: PromotionActionConfig<T, U>) {
super(config);
this.priorityValue = config.priorityValue || 0;
this.conditions = config.conditions;
this.onActivateFn = config.onActivate;
this.onDeactivateFn = config.onDeactivate;
}

/** @internal */
abstract execute(...arg: any[]): number | Promise<number>;

/** @internal */
onActivate(ctx: RequestContext, order: Order, args: ConfigArg[]): void | Promise<void> {
return this.onActivateFn?.(ctx, order, this.argsArrayToHash(args));
}

/** @internal */
onDeactivate(ctx: RequestContext, order: Order, args: ConfigArg[]): void | Promise<void> {
return this.onDeactivateFn?.(ctx, order, this.argsArrayToHash(args));
}
}

Expand All @@ -244,7 +307,7 @@ export abstract class PromotionAction<
*/
export class PromotionItemAction<
T extends ConfigArgs = ConfigArgs,
U extends Array<PromotionCondition<any>> = []
U extends Array<PromotionCondition<any>> = [],
> extends PromotionAction<T, U> {
private readonly executeFn: ExecutePromotionItemActionFn<T, U>;
constructor(config: PromotionItemActionConfig<T, U>) {
Expand Down Expand Up @@ -299,7 +362,7 @@ export class PromotionItemAction<
*/
export class PromotionOrderAction<
T extends ConfigArgs = ConfigArgs,
U extends PromotionCondition[] = []
U extends PromotionCondition[] = [],
> extends PromotionAction<T, U> {
private readonly executeFn: ExecutePromotionOrderActionFn<T, U>;
constructor(config: PromotionOrderActionConfig<T, U>) {
Expand All @@ -309,7 +372,12 @@ export class PromotionOrderAction<

/** @internal */
execute(ctx: RequestContext, order: Order, args: ConfigArg[], state: PromotionState) {
const actionState = this.conditions ? pick(state, this.conditions.map(c => c.code)) : {};
const actionState = this.conditions
? pick(
state,
this.conditions.map(c => c.code),
)
: {};
return this.executeFn(ctx, order, this.argsArrayToHash(args), actionState as ConditionState<U>);
}
}
Expand All @@ -324,7 +392,7 @@ export class PromotionOrderAction<
*/
export class PromotionShippingAction<
T extends ConfigArgs = ConfigArgs,
U extends PromotionCondition[] = []
U extends PromotionCondition[] = [],
> extends PromotionAction<T, U> {
private readonly executeFn: ExecutePromotionShippingActionFn<T, U>;
constructor(config: PromotionShippingActionConfig<T, U>) {
Expand All @@ -340,7 +408,12 @@ export class PromotionShippingAction<
args: ConfigArg[],
state: PromotionState,
) {
const actionState = this.conditions ? pick(state, this.conditions.map(c => c.code)) : {};
const actionState = this.conditions
? pick(
state,
this.conditions.map(c => c.code),
)
: {};
return this.executeFn(
ctx,
shippingLine,
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/entity/promotion/promotion.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,21 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
}
return promotionState;
}

async activate(ctx: RequestContext, order: Order) {
for (const action of this.actions) {
const promotionAction = this.allActions[action.code];
await promotionAction.onActivate(ctx, order, action.args);
}
}

async deactivate(ctx: RequestContext, order: Order) {
for (const action of this.actions) {
const promotionAction = this.allActions[action.code];
await promotionAction.onDeactivate(ctx, order, action.args);
}
}

private isShippingAction(
value: PromotionItemAction | PromotionOrderAction | PromotionShippingAction,
): value is PromotionItemAction {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export class OrderCalculator {
options?: { recalculateShipping?: boolean },
): Promise<OrderItem[]> {
const { taxZoneStrategy } = this.configService.taxOptions;
// We reset the promotions array as all promotions
// must be revalidated on any changes to an Order.
order.promotions = [];
const zones = await this.zoneService.findAll(ctx);
const activeTaxZone = await this.requestContextCache.get(ctx, 'activeTaxZone', () =>
taxZoneStrategy.determineTaxZone(ctx, zones, ctx.channel, order),
Expand Down Expand Up @@ -249,6 +252,7 @@ export class OrderCalculator {
this.calculateOrderTotals(order);
priceAdjusted = false;
}
this.addPromotion(order, promotion);
}
}
const lineNoLongerHasPromotions = !line.firstItem?.adjustments?.find(
Expand Down Expand Up @@ -355,6 +359,7 @@ export class OrderCalculator {
});
this.calculateOrderTotals(order);
}
this.addPromotion(order, promotion);
}
}
this.calculateOrderTotals(order);
Expand All @@ -380,6 +385,7 @@ export class OrderCalculator {
shippingLine.addAdjustment(adjustment);
}
}
this.addPromotion(order, promotion);
}
}
} else {
Expand Down Expand Up @@ -470,4 +476,10 @@ export class OrderCalculator {
order.shipping = shippingPrice;
order.shippingWithTax = shippingPriceWithTax;
}

private addPromotion(order: Order, promotion: Promotion) {
if (order.promotions && !order.promotions.find(p => idsAreEqual(p.id, promotion.id))) {
order.promotions.push(promotion);
}
}
}
30 changes: 15 additions & 15 deletions packages/core/src/service/helpers/order-modifier/order-modifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,15 +463,8 @@ export class OrderModifier {
}

const updatedOrderLines = order.lines.filter(l => updatedOrderLineIds.includes(l.id));
const promotions = await this.connection
.getRepository(ctx, Promotion)
.createQueryBuilder('promotion')
.leftJoin('promotion.channels', 'channel')
.where('channel.id = :channelId', { channelId: ctx.channelId })
.andWhere('promotion.deletedAt IS NULL')
.andWhere('promotion.enabled = :enabled', { enabled: true })
.orderBy('promotion.priorityScore', 'ASC')
.getMany();
const promotions = await this.promotionService.getActivePromotionsInChannel(ctx);
const activePromotionsPre = await this.promotionService.getActivePromotionsOnOrder(ctx, order.id);
await this.orderCalculator.applyPriceAdjustments(ctx, order, promotions, updatedOrderLines, {
recalculateShipping: input.options?.recalculateShipping,
});
Expand All @@ -481,6 +474,8 @@ export class OrderModifier {
patchEntity(order, { customFields: orderCustomFields });
}

await this.promotionService.runPromotionSideEffects(ctx, order, activePromotionsPre);

if (dryRun) {
return { order, modification };
}
Expand All @@ -496,7 +491,11 @@ export class OrderModifier {
if (shippingDelta < 0) {
refundInput.shipping = shippingDelta * -1;
}
refundInput.adjustment += await this.getAdjustmentFromNewlyAppliedPromotions(ctx, order);
refundInput.adjustment += await this.getAdjustmentFromNewlyAppliedPromotions(
ctx,
order,
activePromotionsPre,
);
const existingPayments = await this.getOrderPayments(ctx, order.id);
const payment = existingPayments.find(p => idsAreEqual(p.id, input.refund?.paymentId));
if (payment) {
Expand Down Expand Up @@ -525,7 +524,6 @@ export class OrderModifier {
// OrderItems. So in this case we need to save all of them.
const orderItems = order.lines.reduce((all, line) => all.concat(line.items), [] as OrderItem[]);
await this.connection.getRepository(ctx, OrderItem).save(orderItems, { reload: false });
await this.promotionService.addPromotionsToOrder(ctx, order);
} else {
// Otherwise, just save those OrderItems that were specifically added/removed
await this.connection
Expand All @@ -548,13 +546,15 @@ export class OrderModifier {
return noChanges;
}

private async getAdjustmentFromNewlyAppliedPromotions(ctx: RequestContext, order: Order) {
await this.entityHydrator.hydrate(ctx, order, { relations: ['promotions'] });
const existingPromotions = order.promotions;
private async getAdjustmentFromNewlyAppliedPromotions(
ctx: RequestContext,
order: Order,
promotionsPre: Promotion[],
) {
const newPromotionDiscounts = order.discounts
.filter(discount => {
const promotionId = AdjustmentSource.decodeSourceId(discount.adjustmentSource).id;
return !existingPromotions.find(p => idsAreEqual(p.id, promotionId));
return !promotionsPre.find(p => idsAreEqual(p.id, promotionId));
})
.filter(discount => {
// Filter out any discounts that originate from ShippingLine discounts,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,6 @@ export class OrderStateMachine {
if (shouldSetAsPlaced) {
order.active = false;
order.orderPlacedAt = new Date();
await this.promotionService.addPromotionsToOrder(ctx, order);
this.eventBus.publish(new OrderPlacedEvent(fromState, toState, ctx, order));
}
}
Expand Down
Loading

0 comments on commit 1a4a117

Please sign in to comment.