diff --git a/packages/core/e2e/order-promotion.e2e-spec.ts b/packages/core/e2e/order-promotion.e2e-spec.ts index a710a50bd8..04658f86e9 100644 --- a/packages/core/e2e/order-promotion.e2e-spec.ts +++ b/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(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); diff --git a/packages/core/src/config/promotion/promotion-action.ts b/packages/core/src/config/promotion/promotion-action.ts index 568c8e9e9a..d344eecd2e 100644 --- a/packages/core/src/config/promotion/promotion-action.ts +++ b/packages/core/src/config/promotion/promotion-action.ts @@ -59,7 +59,7 @@ type TupleToUnion = T[number]; */ export type ConditionState< U extends Array>, - T extends [string, any] = TupleToUnion>> + T extends [string, any] = TupleToUnion>>, > = { [key in T[0]]: Extract[1] }; /** @@ -103,7 +103,7 @@ export type ExecutePromotionOrderActionFn> + U extends Array>, > = ( ctx: RequestContext, shippingLine: ShippingLine, @@ -112,6 +112,21 @@ export type ExecutePromotionShippingActionFn< state: ConditionState, ) => number | Promise; +/** + * @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 = ( + ctx: RequestContext, + order: Order, + args: ConfigArgValues, +) => void | Promise; + /** * @description * Configuration for all types of {@link PromotionAction}. @@ -121,7 +136,7 @@ export type ExecutePromotionShippingActionFn< */ export interface PromotionActionConfig< T extends ConfigArgs, - U extends Array> | undefined + U extends Array> | undefined, > extends ConfigurableOperationDefOptions { /** * @description @@ -142,6 +157,28 @@ export interface PromotionActionConfig< * the return values of the PromotionConditions' `check()` function. */ conditions?: U extends undefined ? undefined : ConditionTuple>; + /** + * @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; + + /** + * @description + * Used to reverse or clean up any side effects executed as part of the `onActivate` function. + * + * @since 1.8.0 + * @experimental + */ + onDeactivate?: PromotionActionSideEffectFn; } /** @@ -156,6 +193,8 @@ export interface PromotionItemActionConfig; } @@ -171,6 +210,8 @@ export interface PromotionOrderActionConfig; } @@ -186,6 +227,8 @@ export interface PromotionShippingActionConfig; } @@ -201,7 +244,7 @@ export interface PromotionShippingActionConfig extends ConfigurableOperationDef { /** * @description @@ -212,12 +255,32 @@ export abstract class PromotionAction< * @default 0 */ readonly priorityValue: number; + /** @internal */ readonly conditions?: U; + /** @internal */ + protected readonly onActivateFn?: PromotionActionSideEffectFn; + /** @internal */ + protected readonly onDeactivateFn?: PromotionActionSideEffectFn; protected constructor(config: PromotionActionConfig) { 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; + + /** @internal */ + onActivate(ctx: RequestContext, order: Order, args: ConfigArg[]): void | Promise { + return this.onActivateFn?.(ctx, order, this.argsArrayToHash(args)); + } + + /** @internal */ + onDeactivate(ctx: RequestContext, order: Order, args: ConfigArg[]): void | Promise { + 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> = [] + U extends Array> = [], > extends PromotionAction { private readonly executeFn: ExecutePromotionItemActionFn; constructor(config: PromotionItemActionConfig) { @@ -299,7 +362,7 @@ export class PromotionItemAction< */ export class PromotionOrderAction< T extends ConfigArgs = ConfigArgs, - U extends PromotionCondition[] = [] + U extends PromotionCondition[] = [], > extends PromotionAction { private readonly executeFn: ExecutePromotionOrderActionFn; constructor(config: PromotionOrderActionConfig) { @@ -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); } } @@ -324,7 +392,7 @@ export class PromotionOrderAction< */ export class PromotionShippingAction< T extends ConfigArgs = ConfigArgs, - U extends PromotionCondition[] = [] + U extends PromotionCondition[] = [], > extends PromotionAction { private readonly executeFn: ExecutePromotionShippingActionFn; constructor(config: PromotionShippingActionConfig) { @@ -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, diff --git a/packages/core/src/entity/promotion/promotion.entity.ts b/packages/core/src/entity/promotion/promotion.entity.ts index a1c1b3e094..7fe094cb25 100644 --- a/packages/core/src/entity/promotion/promotion.entity.ts +++ b/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 { diff --git a/packages/core/src/service/helpers/order-calculator/order-calculator.ts b/packages/core/src/service/helpers/order-calculator/order-calculator.ts index eda0606af1..ece3230e75 100644 --- a/packages/core/src/service/helpers/order-calculator/order-calculator.ts +++ b/packages/core/src/service/helpers/order-calculator/order-calculator.ts @@ -56,6 +56,9 @@ export class OrderCalculator { options?: { recalculateShipping?: boolean }, ): Promise { 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); + } + } } diff --git a/packages/core/src/service/helpers/order-modifier/order-modifier.ts b/packages/core/src/service/helpers/order-modifier/order-modifier.ts index 3f64d92962..e9fd770a85 100644 --- a/packages/core/src/service/helpers/order-modifier/order-modifier.ts +++ b/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, diff --git a/packages/core/src/service/helpers/order-state-machine/order-state-machine.ts b/packages/core/src/service/helpers/order-state-machine/order-state-machine.ts index 6789c8a284..0e3ac7493d 100644 --- a/packages/core/src/service/helpers/order-state-machine/order-state-machine.ts +++ b/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)); } } diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index 73fc7eac83..afa9a6697a 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/packages/core/src/service/services/order.service.ts @@ -1716,6 +1716,9 @@ export class OrderService { order: Order, updatedOrderLines?: OrderLine[], ): Promise { + 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 { diff --git a/packages/core/src/service/services/promotion.service.ts b/packages/core/src/service/services/promotion.service.ts index e3abb13394..0f45f0243a 100644 --- a/packages/core/src/service/services/promotion.service.ts +++ b/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 { + 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 { const allPromotionIds = order.discounts.map(