Bläddra i källkod

Merge branch 'promotion-side-effects' into major

Michael Bromley 3 år sedan
förälder
incheckning
fae401895d

+ 7 - 0
docs/README.md

@@ -90,6 +90,12 @@ class.
 
 The @since tag indicates that a class, method, or other symbol was added in a specific version.
 
+##### `@experimental`
+
+The @experimental tag indicates that a class, method, or other symbol is part of an experimental API and 
+may be subject to changes. This is used to allow the testing of new features to gather developer feedback before
+marking the API as stable
+
 ##### Example
 
 ````ts
@@ -106,6 +112,7 @@ The @since tag indicates that a class, method, or other symbol was added in a sp
  * ```
  *
  * @docsCategory helpers
+ * @since 1.2.3
  */
 export class Greeter {
 

+ 90 - 0
docs/content/plugins/extending-the-admin-ui/bulk-actions/_index.md

@@ -0,0 +1,90 @@
+---
+title: 'Bulk Actions'
+weight: 7
+---
+
+# Bulk Actions
+
+Certain list views in the Admin UI support bulk actions. There are a default set of bulk actions that are defined by the Admin UI itself (e.g. delete, assign to channels), but using the `@vendure/ui-devit` package
+you are also able to define your own bulk actions.
+
+{{< figure src="./bulk-actions-screenshot.png" >}} 
+
+Use cases for bulk actions include things like:
+
+- Sending multiple products to a 3rd-party localization service
+- Exporting selected products to csv 
+- Bulk-updating custom field data
+
+## Bulk Action Example
+
+A bulk action must be provided to a [ui-extension shared module]({{< relref "extending-the-admin-ui" >}}#lazy-vs-shared-modules) using the [`registerBulkAction` function]({{< relref "register-bulk-action" >}})
+
+```TypeScript
+import { NgModule } from '@angular/core';
+import { ModalService, registerBulkAction, SharedModule } from '@vendure/admin-ui/core';
+
+@NgModule({
+  imports: [SharedModule],
+  providers: [
+    ProductDataTranslationService,
+      
+    // Here is where we define our bulk action
+    // for sending the selected products to a 3rd-party 
+    // translation API  
+    registerBulkAction({
+      // This tells the Admin UI that this bulk action should be made
+      // available on the product list view.  
+      location: 'product-list',
+      label: 'Send to translation service',
+      icon: 'language',
+      // Here is the logic that is executed when the bulk action menu item
+      // is clicked.  
+      onClick: ({ injector, selection }) => {
+        const modalService = injector.get(ModalService);
+        const translationService = injector.get(ProductDataTranslationService);
+        modalService
+          .dialog({
+            title: `Send ${selection.length} products for translation?`,
+            buttons: [
+              { type: 'secondary', label: 'cancel' },
+              { type: 'primary', label: 'send', returnValue: true },
+            ],
+          })
+          .subscribe(response => {
+            if (response) {
+              translationService.sendForTranslation(selection.map(item => item.productId));
+            }
+          });
+      },
+    }),
+  ],
+})
+export class MyUiExtensionModule {}
+```
+
+## Conditionally displaying bulk actions
+
+Sometimes a bulk action only makes sense in certain circumstances. For example, the "assign to channel" action only makes sense when your server has multiple channels set up.
+
+We can conditionally control the display of a bulk action with the `isVisible` function, which should return a Promise resolving to a boolean:
+
+```TypeScript
+import { registerBulkAction, DataService } from '@vendure/admin-ui/core';
+
+registerBulkAction({
+  location: 'product-list',
+  label: 'Assign to channel',
+  // Only display this action if there are multiple channels
+  isVisible: ({ injector }) => injector.get(DataService).client
+    .userStatus()
+    .mapSingle(({ userStatus }) => 1 < userStatus.channels.length)
+    .toPromise(),
+  // ...  
+});
+```
+
+Related API docs:
+
+- [`registerBulkAction`]({{< relref "register-bulk-action" >}})
+- [`BulkAction`]({{< relref "bulk-action" >}})

BIN
docs/content/plugins/extending-the-admin-ui/bulk-actions/bulk-actions-screenshot.png


+ 1 - 3
packages/admin-ui/src/lib/core/src/providers/bulk-action-registry/bulk-action-registry.service.ts

@@ -1,6 +1,4 @@
-import { Injectable, Type } from '@angular/core';
-
-import { FormInputComponent } from '../../common/component-registry-types';
+import { Injectable } from '@angular/core';
 
 import { BulkAction, BulkActionLocationId } from './bulk-action-types';
 

+ 30 - 0
packages/admin-ui/src/lib/core/src/providers/bulk-action-registry/bulk-action-types.ts

@@ -116,11 +116,41 @@ export interface BulkAction<ItemType = any, ComponentType = any> {
      *
      * This function will be invoked each time the selection is changed, so try to avoid expensive code
      * running here.
+     *
+     * @example
+     * ```TypeScript
+     * import { registerBulkAction, DataService } from '\@vendure/admin-ui/core';
+     *
+     * registerBulkAction({
+     *   location: 'product-list',
+     *   label: 'Assign to channel',
+     *   // Only display this action if there are multiple channels
+     *   isVisible: ({ injector }) => injector.get(DataService).client
+     *     .userStatus()
+     *     .mapSingle(({ userStatus }) => 1 < userStatus.channels.length)
+     *     .toPromise(),
+     *   // ...
+     * });
+     * ```
      */
     isVisible?: (context: BulkActionFunctionContext<ItemType, ComponentType>) => boolean | Promise<boolean>;
     /**
      * @description
      * Control the display of this item based on the user permissions.
+     *
+     * @example
+     * ```TypeScript
+     * registerBulkAction({
+     *   // Can be specified as a simple string
+     *   requiresPermission: Permission.UpdateProduct,
+     *
+     *   // Or as a function that returns a boolean if permissions are satisfied
+     *   requiresPermission: userPermissions =>
+     *     userPermissions.includes(Permission.UpdateCatalog) ||
+     *     userPermissions.includes(Permission.UpdateProduct),
+     *   // ...
+     * })
+     * ```
      */
     requiresPermission?: string | ((userPermissions: string[]) => boolean);
 }

+ 4 - 2
packages/admin-ui/src/lib/core/src/providers/bulk-action-registry/register-bulk-action.ts

@@ -1,7 +1,6 @@
 import { APP_INITIALIZER, FactoryProvider } from '@angular/core';
 
-import { BulkActionRegistryService } from '../../providers/bulk-action-registry/bulk-action-registry.service';
-
+import { BulkActionRegistryService } from './bulk-action-registry.service';
 import { BulkAction } from './bulk-action-types';
 
 /**
@@ -18,6 +17,9 @@ import { BulkAction } from './bulk-action-types';
  *
  * @example
  * ```TypeScript
+ * import { NgModule } from '\@angular/core';
+ * import { ModalService, registerBulkAction, SharedModule } from '\@vendure/admin-ui/core';
+ *
  * \@NgModule({
  *   imports: [SharedModule],
  *   providers: [

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

@@ -443,6 +443,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',
@@ -453,10 +455,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]),
                             },
                         ],
                     },
@@ -467,14 +466,14 @@ describe('Promotions applied to Orders', () => {
                 CodegenShop.AddItemToOrderMutation,
                 CodegenShop.AddItemToOrderMutationVariables
             >(ADD_ITEM_TO_ORDER, {
-                productVariantId: getVariantBySlug('item-5000').id,
+                productVariantId: item5000.id,
                 quantity: 1,
             });
             const { addItemToOrder } = await shopClient.query<
                 CodegenShop.AddItemToOrderMutation,
                 CodegenShop.AddItemToOrderMutationVariables
             >(ADD_ITEM_TO_ORDER, {
-                productVariantId: getVariantBySlug('item-1000').id,
+                productVariantId: item1000.id,
                 quantity: 1,
             });
             orderResultGuard.assertSuccess(addItemToOrder);
@@ -485,7 +484,7 @@ describe('Promotions applied to Orders', () => {
                 CodegenShop.AdjustItemQuantityMutation,
                 CodegenShop.AdjustItemQuantityMutationVariables
             >(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(

+ 14 - 1
packages/testing/src/config/test-config.ts

@@ -2,6 +2,7 @@ import { ADMIN_API_PATH, SHOP_API_PATH } from '@vendure/common/lib/shared-consta
 import {
     DefaultAssetNamingStrategy,
     defaultConfig,
+    DefaultLogger,
     mergeConfig,
     NoopLogger,
     VendureConfig,
@@ -13,6 +14,8 @@ import { TestingEntityIdStrategy } from './testing-entity-id-strategy';
 
 export const E2E_DEFAULT_CHANNEL_TOKEN = 'e2e-default-channel';
 
+const logger = process.env.LOG ? new DefaultLogger() : new NoopLogger();
+
 /**
  * @description
  * A {@link VendureConfig} object used for e2e tests. This configuration uses sqljs as the database
@@ -24,6 +27,16 @@ export const E2E_DEFAULT_CHANNEL_TOKEN = 'e2e-default-channel';
  * * `assetStorageStrategy: new TestingAssetStorageStrategy()` This strategy does not actually persist any binary data to disk.
  * * `assetPreviewStrategy: new TestingAssetPreviewStrategy()` This strategy is a no-op.
  *
+ * ## Logging
+ * By default, the testConfig does not output any log messages. This is most desirable to keep a clean CI output.
+ * However, for debugging purposes, it can make it hard to figure out why tests fail.
+ *
+ * You can enable default logging behaviour with the environment variable `LOG`:
+ *
+ * ```
+ * LOG=true yarn e2e
+ * ```
+ *
  * @docsCategory testing
  */
 export const testConfig: Required<VendureConfig> = mergeConfig(defaultConfig, {
@@ -54,7 +67,7 @@ export const testConfig: Required<VendureConfig> = mergeConfig(defaultConfig, {
     paymentOptions: {
         paymentMethodHandlers: [],
     },
-    logger: new NoopLogger(),
+    logger,
     importExportOptions: {},
     assetOptions: {
         assetNamingStrategy: new DefaultAssetNamingStrategy(),

+ 1 - 1
packages/testing/src/testing-logger.ts

@@ -3,7 +3,7 @@ import { VendureLogger } from '@vendure/core';
 /**
  * @description
  * The TestingLogger can be used in unit tests or e2e tests to make assertions on whether the various
- * Logger methods have been called, and which which arguments.
+ * Logger methods have been called, and which arguments.
  *
  * Here's some examples of how to use it in e2e tests and unit tests. In both cases we are using
  * the Jest testing framework, but the TestingLogger should work with other similar frameworks

+ 2 - 0
scripts/docs/typescript-docgen-types.ts

@@ -14,6 +14,7 @@ export interface MemberInfo {
     fullText: string;
     modifiers: string[];
     since: string | undefined;
+    experimental: boolean;
 }
 
 export interface PropertyInfo extends MemberInfo {
@@ -44,6 +45,7 @@ export interface DeclarationInfo {
     description: string;
     page: string | undefined;
     since: string | undefined;
+    experimental: boolean;
 }
 
 export interface InterfaceInfo extends DeclarationInfo {

+ 17 - 1
scripts/docs/typescript-docs-parser.ts

@@ -111,6 +111,7 @@ export class TypescriptDocsParser {
         const description = this.getDeclarationDescription(statement);
         const docsPage = this.getDocsPage(statement);
         const since = this.getSince(statement);
+        const experimental = this.getExperimental(statement);
         const packageName = this.getPackageName(sourceFile);
 
         const info = {
@@ -124,6 +125,7 @@ export class TypescriptDocsParser {
             description,
             page: docsPage,
             since,
+            experimental,
         };
 
         if (ts.isInterfaceDeclaration(statement)) {
@@ -226,7 +228,7 @@ export class TypescriptDocsParser {
     }
 
     /**
-     * Parses an array of inteface members into a simple object which can be rendered into markdown.
+     * Parses an array of interface members into a simple object which can be rendered into markdown.
      */
     private parseMembers(
         members: ts.NodeArray<ts.TypeElement | ts.ClassElement | ts.EnumMember>,
@@ -259,6 +261,7 @@ export class TypescriptDocsParser {
                 let fullText = '';
                 let isInternal = false;
                 let since: string | undefined;
+                let experimental = false;
                 if (ts.isConstructorDeclaration(member)) {
                     fullText = 'constructor';
                 } else if (ts.isMethodDeclaration(member)) {
@@ -274,6 +277,7 @@ export class TypescriptDocsParser {
                     default: comment => (defaultValue = comment || ''),
                     internal: comment => (isInternal = true),
                     since: comment => (since = comment || undefined),
+                    experimental: comment => (experimental = comment != null),
                 });
                 if (isInternal) {
                     continue;
@@ -288,6 +292,7 @@ export class TypescriptDocsParser {
                     type,
                     modifiers,
                     since,
+                    experimental,
                 };
                 if (
                     ts.isMethodSignature(member) ||
@@ -358,6 +363,17 @@ export class TypescriptDocsParser {
         return since;
     }
 
+    /**
+     * Reads the @experimental JSDoc tag
+     */
+    private getExperimental(statement: ValidDeclaration): boolean {
+        let experimental = false;
+        this.parseTags(statement, {
+            experimental: comment => (experimental = comment != null),
+        });
+        return experimental;
+    }
+
     /**
      * Reads the @description JSDoc tag from the interface.
      */

+ 10 - 2
scripts/docs/typescript-docs-renderer.ts

@@ -283,6 +283,7 @@ export class TypescriptDocsRenderer {
         for (const member of members || []) {
             let defaultParam = '';
             let sinceParam = '';
+            let experimentalParam = '';
             let type = '';
             if (member.kind === 'property') {
                 type = this.renderType(member.type, knownTypeMap, docsUrl);
@@ -302,10 +303,13 @@ export class TypescriptDocsRenderer {
             if (member.since) {
                 sinceParam = `since="${member.since}" `;
             }
+            if (member.experimental) {
+                experimentalParam = 'experimental="true"';
+            }
             output += `### ${member.name}\n\n`;
             output += `{{< member-info kind="${[...member.modifiers, member.kind].join(
                 ' ',
-            )}" type="${type}" ${defaultParam} ${sinceParam}>}}\n\n`;
+            )}" type="${type}" ${defaultParam} ${sinceParam}${experimentalParam}>}}\n\n`;
             output += `{{< member-description >}}${this.renderDescription(
                 member.description,
                 knownTypeMap,
@@ -334,7 +338,11 @@ export class TypescriptDocsRenderer {
         if (info.since) {
             sinceData = ` since="${info.since}"`;
         }
-        return `{{< generation-info sourceFile="${sourceFile}" sourceLine="${info.sourceLine}" packageName="${info.packageName}"${sinceData}>}}\n\n`;
+        let experimental = '';
+        if (info.experimental) {
+            experimental = ` experimental="true"`;
+        }
+        return `{{< generation-info sourceFile="${sourceFile}" sourceLine="${info.sourceLine}" packageName="${info.packageName}"${sinceData}${experimental}>}}\n\n`;
     }
 
     /**