Explorar el Código

feat(core): Add support for PromotionAction side effects

Relates to #1798
Michael Bromley hace 3 años
padre
commit
1a4a117f6f

+ 6 - 7
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -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',
@@ -486,10 +488,7 @@ 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]),
                             },
                         ],
                     },
@@ -497,14 +496,14 @@ describe('Promotions applied to Orders', () => {
                 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);
@@ -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);

+ 82 - 9
packages/core/src/config/promotion/promotion-action.ts

@@ -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] };
 
 /**
@@ -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,
@@ -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}.
@@ -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
@@ -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>;
 }
 
 /**
@@ -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>;
 }
@@ -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>;
 }
@@ -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>;
 }
@@ -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
@@ -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));
     }
 }
 
@@ -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>) {
@@ -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>) {
@@ -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>);
     }
 }
@@ -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>) {
@@ -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,

+ 15 - 0
packages/core/src/entity/promotion/promotion.entity.ts

@@ -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 {

+ 12 - 0
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -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),
@@ -249,6 +252,7 @@ export class OrderCalculator {
                         this.calculateOrderTotals(order);
                         priceAdjusted = false;
                     }
+                    this.addPromotion(order, promotion);
                 }
             }
             const lineNoLongerHasPromotions = !line.firstItem?.adjustments?.find(
@@ -355,6 +359,7 @@ export class OrderCalculator {
                         });
                         this.calculateOrderTotals(order);
                     }
+                    this.addPromotion(order, promotion);
                 }
             }
             this.calculateOrderTotals(order);
@@ -380,6 +385,7 @@ export class OrderCalculator {
                             shippingLine.addAdjustment(adjustment);
                         }
                     }
+                    this.addPromotion(order, promotion);
                 }
             }
         } else {
@@ -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);
+        }
+    }
 }

+ 15 - 15
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -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,
         });
@@ -481,6 +474,8 @@ export class OrderModifier {
             patchEntity(order, { customFields: orderCustomFields });
         }
 
+        await this.promotionService.runPromotionSideEffects(ctx, order, activePromotionsPre);
+
         if (dryRun) {
             return { order, modification };
         }
@@ -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) {
@@ -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
@@ -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,

+ 0 - 1
packages/core/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -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));
             }
         }

+ 7 - 12
packages/core/src/service/services/order.service.ts

@@ -1716,6 +1716,9 @@ export class OrderService {
         order: Order,
         updatedOrderLines?: OrderLine[],
     ): Promise<Order> {
+        const promotions = await this.promotionService.getActivePromotionsInChannel(ctx);
+        const activePromotionsPre = await this.promotionService.getActivePromotionsOnOrder(ctx, order.id);
+
         if (updatedOrderLines?.length) {
             const { orderItemPriceCalculationStrategy, changedPriceHandlingStrategy } =
                 this.configService.orderOptions;
@@ -1729,7 +1732,7 @@ export class OrderService {
                     ctx,
                     variant,
                     updatedOrderLine.customFields || {},
-                    order
+                    order,
                 );
                 const initialListPrice =
                     updatedOrderLine.items.find(i => i.initialListPrice != null)?.initialListPrice ??
@@ -1752,16 +1755,6 @@ export class OrderService {
             }
         }
 
-        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 updatedItems = await this.orderCalculator.applyPriceAdjustments(
             ctx,
             order,
@@ -1789,7 +1782,9 @@ export class OrderService {
             .execute();
         await this.connection.getRepository(ctx, Order).save(order, { reload: false });
         await this.connection.getRepository(ctx, ShippingLine).save(order.shippingLines, { reload: false });
-        return order;
+        await this.promotionService.runPromotionSideEffects(ctx, order, activePromotionsPre);
+
+        return assertFound(this.findOne(ctx, order.id));
     }
 
     private async getOrderWithFulfillments(ctx: RequestContext, orderId: ID): Promise<Order> {

+ 40 - 0
packages/core/src/service/services/promotion.service.ts

@@ -250,9 +250,49 @@ export class PromotionService {
         return promotion;
     }
 
+    getActivePromotionsInChannel(ctx: RequestContext) {
+        return 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();
+    }
+
+    async getActivePromotionsOnOrder(ctx: RequestContext, orderId: ID): Promise<Promotion[]> {
+        const order = await this.connection
+            .getRepository(ctx, Order)
+            .createQueryBuilder('order')
+            .leftJoinAndSelect('order.promotions', 'promotions')
+            .where('order.id = :orderId', { orderId })
+            .getOne();
+        return order?.promotions ?? [];
+    }
+
+    async runPromotionSideEffects(ctx: RequestContext, order: Order, promotionsPre: Promotion[]) {
+        const promotionsPost = order.promotions;
+        for (const activePre of promotionsPre) {
+            if (!promotionsPost.find(p => idsAreEqual(p.id, activePre.id))) {
+                // activePre is no longer active, so call onDeactivate
+                await activePre.deactivate(ctx, order);
+            }
+        }
+        for (const activePost of promotionsPost) {
+            if (!promotionsPre.find(p => idsAreEqual(p.id, activePost.id))) {
+                // activePost was not previously active, so call onActivate
+                await activePost.activate(ctx, order);
+            }
+        }
+    }
+
     /**
      * @description
      * Used internally to associate a Promotion with an Order, once an Order has been placed.
+     *
+     * @deprecated This method is no longer used and will be removed in v2.0
      */
     async addPromotionsToOrder(ctx: RequestContext, order: Order): Promise<Order> {
         const allPromotionIds = order.discounts.map(