Переглянути джерело

Merge branch 'feat-promotion-state'

Michael Bromley 4 роки тому
батько
коміт
04d257162d

+ 76 - 20
docs/content/docs/developer-guide/promotions.md

@@ -1,13 +1,14 @@
 ---
-title: "Promotions"
+title: 'Promotions'
 showtoc: true
 ---
+
 # Promotions
 
 Promotions are a means of offering discounts on an order based on various criteria. A Promotion consists of _conditions_ and _actions_.
 
-* **conditions** are the rules which determine whether the Promotion should be applied to the order.
-* **actions** specify exactly how this Promotion should modify the order.
+-   **conditions** are the rules which determine whether the Promotion should be applied to the order.
+-   **actions** specify exactly how this Promotion should modify the order.
 
 ## Parts of a Promotion
 
@@ -15,9 +16,9 @@ Promotions are a means of offering discounts on an order based on various criter
 
 All Promotions can have the following constraints applied to them:
 
-* **Date range** Using the "starts at" and "ends at" fields, the Promotion can be scheduled to only be active during the given date range.
-* **Coupon code** A Promotion can require a coupon code first be activated using the [`applyCouponCode` mutation]({{< relref "/docs/graphql-api/shop/mutations" >}}#applycouponcode) in the Shop API.
-* **Per-customer limit** A Promotion coupon may be limited to a given number of uses per Customer.
+-   **Date range** Using the "starts at" and "ends at" fields, the Promotion can be scheduled to only be active during the given date range.
+-   **Coupon code** A Promotion can require a coupon code first be activated using the [`applyCouponCode` mutation]({{< relref "/docs/graphql-api/shop/mutations" >}}#applycouponcode) in the Shop API.
+-   **Per-customer limit** A Promotion coupon may be limited to a given number of uses per Customer.
 
 ### Conditions
 
@@ -33,7 +34,7 @@ Vendure comes with some built-in actions, but you can also create your own actio
 
 ## Creating custom conditions
 
-To create a custom condition, you need to define a new [`PromotionCondition` object]({{< relref "promotion-condition" >}}). 
+To create a custom condition, you need to define a new [`PromotionCondition` object]({{< relref "promotion-condition" >}}).
 Here is an annotated example of one of the built-in PromotionsConditions:
 
 ```TypeScript
@@ -43,8 +44,8 @@ export const minimumOrderAmount = new PromotionCondition({
   /** A unique identifier for the condition */
   code: 'minimum_order_amount',
 
-  /** 
-   * A human-readable description. Values defined in the 
+  /**
+   * A human-readable description. Values defined in the
    * `args` object can be interpolated using the curly-braces syntax.
    */
   description: [
@@ -52,8 +53,8 @@ export const minimumOrderAmount = new PromotionCondition({
   ],
 
   /**
-   * Arguments which can be specified when configuring the condition 
-   * in the Admin UI. The values of these args are then available during 
+   * Arguments which can be specified when configuring the condition
+   * in the Admin UI. The values of these args are then available during
    * the execution of the `check` function.
    */
   args: {
@@ -67,7 +68,7 @@ export const minimumOrderAmount = new PromotionCondition({
   },
 
   /**
-   * This is the business logic of the condition. It is a function that 
+   * This is the business logic of the condition. It is a function that
    * must resolve to a boolean value indicating whether the condition has
    * been satisfied.
    */
@@ -102,18 +103,18 @@ export const config: VendureConfig = {
 
 There are two kinds of PromotionAction:
 
-* [`PromotionItemAction`]({{< relref "promotion-action" >}}#promotionitemaction) applies a discount on the OrderItem level, i.e. it would be used for a promotion like "50% off USB cables".
-* [`PromotionOrderAction`]({{< relref "promotion-action" >}}#promotionorderaction) applies a discount on the Order level, i.e. it would be used for a promotion like "5% off the order total".
+-   [`PromotionItemAction`]({{< relref "promotion-action" >}}#promotionitemaction) applies a discount on the OrderItem level, i.e. it would be used for a promotion like "50% off USB cables".
+-   [`PromotionOrderAction`]({{< relref "promotion-action" >}}#promotionorderaction) applies a discount on the Order level, i.e. it would be used for a promotion like "5% off the order total".
 
 Their implementations are similar, with the difference being the arguments passed to the `execute()` function of each.
 
-Here's an example of a simple PromotionOrderAction. 
+Here's an example of a simple PromotionOrderAction.
 
 ```TypeScript
 import { LanguageCode, PromotionOrderAction } from '@vendure/core';
 
 export const orderPercentageDiscount = new PromotionOrderAction({
-  // See the custom condition example above for explanations 
+  // See the custom condition example above for explanations
   // of code, description & args fields.
   code: 'order_percentage_discount',
   description: [{ languageCode: LanguageCode.en, value: 'Discount order by { discount }%' }],
@@ -156,8 +157,63 @@ export const config: VendureConfig = {
 }
 ```
 
-{{% alert %}}
-**Dependency Injection**
+## Dependency relationships
+
+It is possible to establish dependency relationships between a PromotionAction and one or more PromotionConditions.
+
+For example, if we want to set up a "buy 1, get 1 free" offer, we need to:
+
+1. Establish whether the Order contains the particular ProductVariant under offer (done in the PromotionCondition)
+2. Apply a discount to the qualifying OrderItem (done in the PromotionAction)
+
+In this scenario, we would have to repeat the logic for checking the Order contents in _both_ the PromotionCondition _and_ the PromotionAction. Not only is this duplicated work for the server, it also means that setting up the promotion relies on the same parameters being input into the PromotionCondition and the PromotionAction.
+
+Instead, we can say that the PromotionAction _depends_ on the PromotionCondition:
+
+```TypeScript {hl_lines=[8,10]}
+export const buy1Get1FreeAction = new PromotionItemAction({
+  code: 'buy_1_get_1_free',
+  description: [{
+    languageCode: LanguageCode.en,
+    value: 'Buy 1, get 1 free',
+  }],
+  args: {},
+  conditions: [buyXGetYFreeCondition],
+  execute(ctx, orderItem, orderLine, args, state) {
+      const freeItemIds = state.buy_x_get_y_free.freeItemIds;
+      if (idsContainsItem(freeItemIds, orderItem)) {
+          const unitPrice = ctx.channel.pricesIncludeTax ? orderLine.unitPriceWithTax : orderLine.unitPrice;
+          return -unitPrice;
+      }
+      return 0;
+  },
+});
+```
+
+In the above code, we are stating that this PromotionAction _depends_ on the `buyXGetYFreeCondition` PromotionCondition. Attempting to create a Promotion using the `buy1Get1FreeAction` without also using the `buyXGetYFreeCondition` will result in an error.
+
+In turn, the `buyXGetYFreeCondition` can return a _state object_ with the type `{ [key: string]: any; }` instead of just a `true` boolean value. This state object is then passed to the PromotionConditions which depend on it, as part of the last argument (`state`).
+
+```TypeScript {hl_lines=[15]}
+export const buyXGetYFreeCondition = new PromotionCondition({
+  code: 'buy_x_get_y_free',
+  description: [{
+    languageCode: LanguageCode.en,
+    value: 'Buy { amountX } of { variantIdsX } products, get { amountY } of { variantIdsY } products free',
+  }],
+  args: {
+    // omitted for brevity
+  },
+  async check(ctx, order, args) {
+    // logic omitted for brevity
+    if (freeItemIds.length === 0) {
+      return false;
+    }  
+    return { freeItemIds };
+  },
+});
+```
+
+## Injecting providers
 
-If your PromotionCondition or PromotionAction needs access to the database or other providers, see the [ConfigurableOperationDef Dependency Injection guide]({{< relref "configurable-operation-def" >}}#dependency-injection).
-{{< /alert >}}
+If your PromotionCondition or PromotionAction needs access to the database or other providers, they can be injected by defining an `init()` function in your PromotionAction or PromotionCondition. See the [ConfigurableOperationDef Dependency Injection guide]({{< relref "configurable-operation-def" >}}#dependency-injection) for details.

+ 3 - 1
packages/core/src/common/types/adjustment-source.ts

@@ -3,6 +3,8 @@ import { ID } from '@vendure/common/lib/shared-types';
 
 import { VendureEntity } from '../../entity/base/base.entity';
 
+export type TestResult = boolean | object;
+
 export abstract class AdjustmentSource extends VendureEntity {
     type: AdjustmentType;
 
@@ -18,6 +20,6 @@ export abstract class AdjustmentSource extends VendureEntity {
         };
     }
 
-    abstract test(...args: any[]): boolean | Promise<boolean>;
+    abstract test(...args: any[]): TestResult | Promise<TestResult>;
     abstract apply(...args: any[]): Adjustment | undefined | Promise<Adjustment | undefined>;
 }

+ 32 - 0
packages/core/src/config/promotion/actions/buy-x-get-y-free-action.ts

@@ -0,0 +1,32 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
+
+import { idsAreEqual } from '../../../common/utils';
+import { OrderItem } from '../../../entity';
+import { buyXGetYFreeCondition } from '../conditions/buy-x-get-y-free-condition';
+import { PromotionItemAction } from '../promotion-action';
+
+export const buyXGetYFreeAction = new PromotionItemAction({
+    code: 'buy_x_get_y_free',
+    description: [
+        {
+            languageCode: LanguageCode.en,
+            value:
+                'Buy { amountX } of { variantIdsX } products, get { amountY } of { variantIdsY } products free',
+        },
+    ],
+    args: {},
+    conditions: [buyXGetYFreeCondition],
+    execute(ctx, orderItem, orderLine, args, state) {
+        const freeItemIds = state.buy_x_get_y_free.freeItemIds;
+        if (idsContainsItem(freeItemIds, orderItem)) {
+            const unitPrice = ctx.channel.pricesIncludeTax ? orderLine.unitPriceWithTax : orderLine.unitPrice;
+            return -unitPrice;
+        }
+        return 0;
+    },
+});
+
+function idsContainsItem(ids: ID[], item: OrderItem): boolean {
+    return !!ids.find(id => idsAreEqual(id, item.id));
+}

+ 69 - 0
packages/core/src/config/promotion/conditions/buy-x-get-y-free-condition.ts

@@ -0,0 +1,69 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
+
+import { PromotionCondition } from '../promotion-condition';
+
+export const buyXGetYFreeCondition = new PromotionCondition({
+    code: 'buy_x_get_y_free',
+    description: [
+        {
+            languageCode: LanguageCode.en,
+            value:
+                'Buy { amountX } of { variantIdsX } products, get { amountY } of { variantIdsY } products free',
+        },
+    ],
+    args: {
+        amountX: {
+            type: 'int',
+            defaultValue: 2,
+        },
+        variantIdsX: {
+            type: 'ID',
+            list: true,
+            ui: { component: 'product-selector-form-input' },
+            label: [{ languageCode: LanguageCode.en, value: 'Product variants X' }],
+        },
+        amountY: {
+            type: 'int',
+            defaultValue: 1,
+        },
+        variantIdsY: {
+            type: 'ID',
+            list: true,
+            ui: { component: 'product-selector-form-input' },
+            label: [{ languageCode: LanguageCode.en, value: 'Product variants Y' }],
+        },
+    },
+    async check(ctx, order, args) {
+        const xIds = createIdentityMap(args.variantIdsX);
+        const yIds = createIdentityMap(args.variantIdsY);
+        let matches = 0;
+        const freeItemCandidates = [];
+        for (const line of order.lines) {
+            const variantId = line.productVariant.id;
+            if (variantId in xIds) {
+                matches += line.quantity;
+            }
+            if (variantId in yIds) {
+                freeItemCandidates.push(...line.items);
+            }
+        }
+        const quantity = Math.floor(matches / args.amountX);
+        if (!quantity || !freeItemCandidates.length) return false;
+        const freeItemIds = freeItemCandidates
+            .sort((a, b) => {
+                const unitPriceA = ctx.channel.pricesIncludeTax ? a.unitPriceWithTax : a.unitPrice;
+                const unitPriceB = ctx.channel.pricesIncludeTax ? b.unitPriceWithTax : b.unitPrice;
+                if (unitPriceA < unitPriceB) return -1;
+                if (unitPriceA > unitPriceB) return 1;
+                return 0;
+            })
+            .map(({ id }) => id)
+            .slice(0, quantity * args.amountY);
+        return { freeItemIds };
+    },
+});
+
+function createIdentityMap(ids: ID[]): Record<ID, ID> {
+    return ids.reduce((map: Record<ID, ID>, id) => ({ ...map, [id]: id }), {});
+}

+ 4 - 0
packages/core/src/config/promotion/index.ts

@@ -1,8 +1,10 @@
+import { buyXGetYFreeAction } from './actions/buy-x-get-y-free-action';
 import { discountOnItemWithFacets } from './actions/facet-values-percentage-discount-action';
 import { freeShipping } from './actions/free-shipping-action';
 import { orderFixedDiscount } from './actions/order-fixed-discount-action';
 import { orderPercentageDiscount } from './actions/order-percentage-discount-action';
 import { productsPercentageDiscount } from './actions/product-percentage-discount-action';
+import { buyXGetYFreeCondition } from './conditions/buy-x-get-y-free-condition';
 import { containsProducts } from './conditions/contains-products-condition';
 import { customerGroup } from './conditions/customer-group-condition';
 import { hasFacetValues } from './conditions/has-facet-values-condition';
@@ -25,10 +27,12 @@ export const defaultPromotionActions = [
     discountOnItemWithFacets,
     productsPercentageDiscount,
     freeShipping,
+    buyXGetYFreeAction,
 ];
 export const defaultPromotionConditions = [
     minimumOrderAmount,
     hasFacetValues,
     containsProducts,
     customerGroup,
+    buyXGetYFreeCondition,
 ];

+ 162 - 28
packages/core/src/config/promotion/promotion-action.ts

@@ -1,4 +1,5 @@
 import { ConfigArg } from '@vendure/common/lib/generated-types';
+import { pick } from '@vendure/common/lib/pick';
 
 import { RequestContext } from '../../api/common/request-context';
 import {
@@ -7,11 +8,60 @@ import {
     ConfigurableOperationDef,
     ConfigurableOperationDefOptions,
 } from '../../common/configurable-operation';
+import { PromotionState } from '../../entity';
 import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { OrderLine } from '../../entity/order-line/order-line.entity';
 import { Order } from '../../entity/order/order.entity';
 import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity';
 
+import { PromotionCondition } from './promotion-condition';
+
+/**
+ * Unwrap a promise type
+ */
+type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;
+
+/**
+ * Extract the (non-false) return value of the PromotionCondition "check" function.
+ */
+type ConditionCheckReturnType<T extends PromotionCondition<any>> = Exclude<
+    Awaited<ReturnType<T['check']>>,
+    false
+>;
+
+/**
+ * Converts an array of PromotionCondition types into a tuple, thus preserving the
+ * distinct type of each condition in the array.
+ */
+export type ConditionTuple<C extends Array<PromotionCondition<any>>> = [...C];
+
+/**
+ * Converts an array of PromotionConditions into a tuple of the type:
+ * [<condition code>, <check function return value>]
+ */
+type CodesStateTuple<T extends ConditionTuple<Array<PromotionCondition<any>>>> = {
+    [K in keyof T]: T[K] extends PromotionCondition<any>
+        ? [T[K]['code'], ConditionCheckReturnType<T[K]>]
+        : never;
+};
+
+/**
+ * Convert a tuple into a union
+ * [[string, number], [number, boolean]] => [string, number] | [number, boolean]
+ */
+type TupleToUnion<T extends any[]> = T[number];
+
+/**
+ * Converts an array of PromotionConditions into an object of the type:
+ * {
+ *     [PromotionCondition.code]: ReturnType<PromotionCondition.check()>
+ * }
+ */
+export type ConditionState<
+    U extends Array<PromotionCondition<any>>,
+    T extends [string, any] = TupleToUnion<CodesStateTuple<ConditionTuple<U>>>
+> = { [key in T[0]]: Extract<T, [key, any]>[1] };
+
 /**
  * @description
  * The function which is used by a PromotionItemAction to calculate the
@@ -20,11 +70,12 @@ import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity';
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export type ExecutePromotionItemActionFn<T extends ConfigArgs> = (
+export type ExecutePromotionItemActionFn<T extends ConfigArgs, U extends Array<PromotionCondition<any>>> = (
     ctx: RequestContext,
     orderItem: OrderItem,
     orderLine: OrderLine,
     args: ConfigArgValues<T>,
+    state: ConditionState<U>,
 ) => number | Promise<number>;
 
 /**
@@ -35,10 +86,11 @@ export type ExecutePromotionItemActionFn<T extends ConfigArgs> = (
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export type ExecutePromotionOrderActionFn<T extends ConfigArgs> = (
+export type ExecutePromotionOrderActionFn<T extends ConfigArgs, U extends Array<PromotionCondition<any>>> = (
     ctx: RequestContext,
     order: Order,
     args: ConfigArgValues<T>,
+    state: ConditionState<U>,
 ) => number | Promise<number>;
 
 /**
@@ -49,15 +101,47 @@ export type ExecutePromotionOrderActionFn<T extends ConfigArgs> = (
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export type ExecutePromotionShippingActionFn<T extends ConfigArgs> = (
+export type ExecutePromotionShippingActionFn<
+    T extends ConfigArgs,
+    U extends Array<PromotionCondition<any>>
+> = (
     ctx: RequestContext,
     shippingLine: ShippingLine,
     order: Order,
     args: ConfigArgValues<T>,
+    state: ConditionState<U>,
 ) => number | Promise<number>;
 
-export interface PromotionActionConfig<T extends ConfigArgs> extends ConfigurableOperationDefOptions<T> {
+/**
+ * @description
+ * Configuration for all types of {@link PromotionAction}.
+ *
+ * @docsCategory promotions
+ * @docsPage promotion-action
+ */
+export interface PromotionActionConfig<
+    T extends ConfigArgs,
+    U extends Array<PromotionCondition<any>> | undefined
+> extends ConfigurableOperationDefOptions<T> {
+    /**
+     * @description
+     * Used to determine the order of application of multiple Promotions
+     * on the same Order. See the {@link Promotion} `priorityScore` field for
+     * more information.
+     *
+     * @default 0
+     */
     priorityValue?: number;
+    /**
+     * @description
+     * Allows PromotionActions to define one or more PromotionConditions as dependencies. Having a PromotionCondition
+     * as a dependency has the following consequences:
+     * 1. A Promotion using this PromotionAction is only valid if it also contains all PromotionConditions
+     * on which it depends.
+     * 2. The `execute()` function will receive a statically-typed `state` argument which will contain
+     * the return values of the PromotionConditions' `check()` function.
+     */
+    conditions?: U extends undefined ? undefined : ConditionTuple<Exclude<U, undefined>>;
 }
 
 /**
@@ -67,12 +151,13 @@ export interface PromotionActionConfig<T extends ConfigArgs> extends Configurabl
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export interface PromotionItemActionConfig<T extends ConfigArgs> extends PromotionActionConfig<T> {
+export interface PromotionItemActionConfig<T extends ConfigArgs, U extends PromotionCondition[]>
+    extends PromotionActionConfig<T, U> {
     /**
      * @description
      * The function which contains the promotion calculation logic.
      */
-    execute: ExecutePromotionItemActionFn<T>;
+    execute: ExecutePromotionItemActionFn<T, U>;
 }
 
 /**
@@ -81,12 +166,13 @@ export interface PromotionItemActionConfig<T extends ConfigArgs> extends Promoti
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export interface PromotionOrderActionConfig<T extends ConfigArgs> extends PromotionActionConfig<T> {
+export interface PromotionOrderActionConfig<T extends ConfigArgs, U extends PromotionCondition[]>
+    extends PromotionActionConfig<T, U> {
     /**
      * @description
      * The function which contains the promotion calculation logic.
      */
-    execute: ExecutePromotionOrderActionFn<T>;
+    execute: ExecutePromotionOrderActionFn<T, U>;
 }
 
 /**
@@ -95,23 +181,28 @@ export interface PromotionOrderActionConfig<T extends ConfigArgs> extends Promot
  * @docsCategory promotions
  * @docsPage promotion-action
  */
-export interface PromotionShippingActionConfig<T extends ConfigArgs> extends PromotionActionConfig<T> {
+export interface PromotionShippingActionConfig<T extends ConfigArgs, U extends PromotionCondition[]>
+    extends PromotionActionConfig<T, U> {
     /**
      * @description
      * The function which contains the promotion calculation logic.
      */
-    execute: ExecutePromotionShippingActionFn<T>;
+    execute: ExecutePromotionShippingActionFn<T, U>;
 }
 
 /**
  * @description
- * An abstract class which is extended by {@link PromotionItemAction} and {@link PromotionOrderAction}.
+ * An abstract class which is extended by {@link PromotionItemAction}, {@link PromotionOrderAction},
+ * and {@link PromotionShippingAction}.
  *
  * @docsCategory promotions
  * @docsPage promotion-action
  * @docsWeight 0
  */
-export abstract class PromotionAction<T extends ConfigArgs = {}> extends ConfigurableOperationDef<T> {
+export abstract class PromotionAction<
+    T extends ConfigArgs = {},
+    U extends PromotionCondition[] | undefined = any
+> extends ConfigurableOperationDef<T> {
     /**
      * @description
      * Used to determine the order of application of multiple Promotions
@@ -121,10 +212,12 @@ export abstract class PromotionAction<T extends ConfigArgs = {}> extends Configu
      * @default 0
      */
     readonly priorityValue: number;
+    readonly conditions?: U;
 
-    protected constructor(config: PromotionActionConfig<T>) {
+    protected constructor(config: PromotionActionConfig<T, U>) {
         super(config);
         this.priorityValue = config.priorityValue || 0;
+        this.conditions = config.conditions;
     }
 }
 
@@ -149,16 +242,37 @@ export abstract class PromotionAction<T extends ConfigArgs = {}> extends Configu
  * @docsPage promotion-action
  * @docsWeight 1
  */
-export class PromotionItemAction<T extends ConfigArgs = ConfigArgs> extends PromotionAction<T> {
-    private readonly executeFn: ExecutePromotionItemActionFn<T>;
-    constructor(config: PromotionItemActionConfig<T>) {
+export class PromotionItemAction<
+    T extends ConfigArgs = ConfigArgs,
+    U extends Array<PromotionCondition<any>> = []
+> extends PromotionAction<T, U> {
+    private readonly executeFn: ExecutePromotionItemActionFn<T, U>;
+    constructor(config: PromotionItemActionConfig<T, U>) {
         super(config);
         this.executeFn = config.execute;
     }
 
     /** @internal */
-    execute(ctx: RequestContext, orderItem: OrderItem, orderLine: OrderLine, args: ConfigArg[]) {
-        return this.executeFn(ctx, orderItem, orderLine, this.argsArrayToHash(args));
+    execute(
+        ctx: RequestContext,
+        orderItem: OrderItem,
+        orderLine: OrderLine,
+        args: ConfigArg[],
+        state: PromotionState,
+    ) {
+        const actionState = this.conditions
+            ? pick(
+                  state,
+                  this.conditions.map(c => c.code),
+              )
+            : {};
+        return this.executeFn(
+            ctx,
+            orderItem,
+            orderLine,
+            this.argsArrayToHash(args),
+            actionState as ConditionState<U>,
+        );
     }
 }
 
@@ -183,16 +297,20 @@ export class PromotionItemAction<T extends ConfigArgs = ConfigArgs> extends Prom
  * @docsPage promotion-action
  * @docsWeight 2
  */
-export class PromotionOrderAction<T extends ConfigArgs = ConfigArgs> extends PromotionAction<T> {
-    private readonly executeFn: ExecutePromotionOrderActionFn<T>;
-    constructor(config: PromotionOrderActionConfig<T>) {
+export class PromotionOrderAction<
+    T extends ConfigArgs = ConfigArgs,
+    U extends PromotionCondition[] = []
+> extends PromotionAction<T, U> {
+    private readonly executeFn: ExecutePromotionOrderActionFn<T, U>;
+    constructor(config: PromotionOrderActionConfig<T, U>) {
         super(config);
         this.executeFn = config.execute;
     }
 
     /** @internal */
-    execute(ctx: RequestContext, order: Order, args: ConfigArg[]) {
-        return this.executeFn(ctx, order, this.argsArrayToHash(args));
+    execute(ctx: RequestContext, order: Order, args: ConfigArg[], state: PromotionState) {
+        const actionState = this.conditions ? pick(state, Object.keys(this.conditions)) : {};
+        return this.executeFn(ctx, order, this.argsArrayToHash(args), actionState as ConditionState<U>);
     }
 }
 
@@ -204,15 +322,31 @@ export class PromotionOrderAction<T extends ConfigArgs = ConfigArgs> extends Pro
  * @docsPage promotion-action
  * @docsWeight 3
  */
-export class PromotionShippingAction<T extends ConfigArgs = ConfigArgs> extends PromotionAction<T> {
-    private readonly executeFn: ExecutePromotionShippingActionFn<T>;
-    constructor(config: PromotionShippingActionConfig<T>) {
+export class PromotionShippingAction<
+    T extends ConfigArgs = ConfigArgs,
+    U extends PromotionCondition[] = []
+> extends PromotionAction<T, U> {
+    private readonly executeFn: ExecutePromotionShippingActionFn<T, U>;
+    constructor(config: PromotionShippingActionConfig<T, U>) {
         super(config);
         this.executeFn = config.execute;
     }
 
     /** @internal */
-    execute(ctx: RequestContext, shippingLine: ShippingLine, order: Order, args: ConfigArg[]) {
-        return this.executeFn(ctx, shippingLine, order, this.argsArrayToHash(args));
+    execute(
+        ctx: RequestContext,
+        shippingLine: ShippingLine,
+        order: Order,
+        args: ConfigArg[],
+        state: PromotionState,
+    ) {
+        const actionState = this.conditions ? pick(state, Object.keys(this.conditions)) : {};
+        return this.executeFn(
+            ctx,
+            shippingLine,
+            order,
+            this.argsArrayToHash(args),
+            actionState as ConditionState<U>,
+        );
     }
 }

+ 37 - 10
packages/core/src/config/promotion/promotion-condition.ts

@@ -1,5 +1,4 @@
 import { ConfigArg } from '@vendure/common/lib/generated-types';
-import { ConfigArgType, ID } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
 import {
@@ -8,21 +7,31 @@ import {
     ConfigurableOperationDef,
     ConfigurableOperationDefOptions,
 } from '../../common/configurable-operation';
-import { OrderLine } from '../../entity';
 import { Order } from '../../entity/order/order.entity';
 
+export type PromotionConditionState = Record<string, unknown>;
+
+export type CheckPromotionConditionResult = boolean | PromotionConditionState;
+
 /**
  * @description
  * A function which checks whether or not a given {@link Order} satisfies the {@link PromotionCondition}.
  *
+ * The function should return either a `boolean` or and plain object type:
+ *
+ * * `false`: The condition is not satisfied - do not apply PromotionActions
+ * * `true`: The condition is satisfied, apply PromotionActions
+ * * `{ [key: string]: any; }`: The condition is satisfied, apply PromotionActions
+ * _and_ pass this object into the PromotionAction's `state` argument.
+ *
  * @docsCategory promotions
  * @docsPage promotion-condition
  */
-export type CheckPromotionConditionFn<T extends ConfigArgs> = (
+export type CheckPromotionConditionFn<T extends ConfigArgs, R extends CheckPromotionConditionResult> = (
     ctx: RequestContext,
     order: Order,
     args: ConfigArgValues<T>,
-) => boolean | Promise<boolean>;
+) => R | Promise<R>;
 
 /**
  * @description
@@ -32,8 +41,13 @@ export type CheckPromotionConditionFn<T extends ConfigArgs> = (
  * @docsPage promotion-condition
  * @docsWeight 1
  */
-export interface PromotionConditionConfig<T extends ConfigArgs> extends ConfigurableOperationDefOptions<T> {
-    check: CheckPromotionConditionFn<T>;
+export interface PromotionConditionConfig<
+    T extends ConfigArgs,
+    C extends string,
+    R extends CheckPromotionConditionResult
+> extends ConfigurableOperationDefOptions<T> {
+    code: C;
+    check: CheckPromotionConditionFn<T, R>;
     priorityValue?: number;
 }
 
@@ -47,7 +61,11 @@ export interface PromotionConditionConfig<T extends ConfigArgs> extends Configur
  * @docsPage promotion-condition
  * @docsWeight 0
  */
-export class PromotionCondition<T extends ConfigArgs = ConfigArgs> extends ConfigurableOperationDef<T> {
+export class PromotionCondition<
+    T extends ConfigArgs = ConfigArgs,
+    C extends string = string,
+    R extends CheckPromotionConditionResult = any
+> extends ConfigurableOperationDef<T> {
     /**
      * @description
      * Used to determine the order of application of multiple Promotions
@@ -57,15 +75,24 @@ export class PromotionCondition<T extends ConfigArgs = ConfigArgs> extends Confi
      * @default 0
      */
     readonly priorityValue: number;
-    private readonly checkFn: CheckPromotionConditionFn<T>;
+    private readonly checkFn: CheckPromotionConditionFn<T, R>;
 
-    constructor(config: PromotionConditionConfig<T>) {
+    get code(): C {
+        return super.code as C;
+    }
+
+    constructor(config: PromotionConditionConfig<T, C, R>) {
         super(config);
         this.checkFn = config.check;
         this.priorityValue = config.priorityValue || 0;
     }
 
-    async check(ctx: RequestContext, order: Order, args: ConfigArg[]): Promise<boolean> {
+    /**
+     * @description
+     * This is the function which contains the conditional logic to decide whether
+     * a Promotion should apply to an Order. See {@link CheckPromotionConditionFn}.
+     */
+    async check(ctx: RequestContext, order: Order, args: ConfigArg[]): Promise<R> {
         return this.checkFn(ctx, order, this.argsArrayToHash(args));
     }
 }

+ 23 - 7
packages/core/src/entity/promotion/promotion.entity.ts

@@ -12,7 +12,7 @@ import {
     PromotionOrderAction,
     PromotionShippingAction,
 } from '../../config/promotion/promotion-action';
-import { PromotionCondition } from '../../config/promotion/promotion-condition';
+import { PromotionCondition, PromotionConditionState } from '../../config/promotion/promotion-condition';
 import { Channel } from '../channel/channel.entity';
 import { OrderItem } from '../order-item/order-item.entity';
 import { OrderLine } from '../order-line/order-line.entity';
@@ -33,6 +33,12 @@ export interface ApplyShippingActionArgs {
     order: Order;
 }
 
+export interface PromotionState {
+    [code: string]: PromotionConditionState;
+}
+
+export type PromotionTestResult = boolean | PromotionState;
+
 /**
  * @description
  * A Promotion is used to define a set of conditions under which promotions actions (typically discounts)
@@ -113,8 +119,10 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
     async apply(
         ctx: RequestContext,
         args: ApplyOrderActionArgs | ApplyOrderItemActionArgs | ApplyShippingActionArgs,
+        state?: PromotionState,
     ): Promise<Adjustment | undefined> {
         let amount = 0;
+        state = state || {};
 
         for (const action of this.actions) {
             const promotionAction = this.allActions[action.code];
@@ -122,19 +130,19 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
                 if (this.isOrderItemArg(args)) {
                     const { orderItem, orderLine } = args;
                     amount += Math.round(
-                        await promotionAction.execute(ctx, orderItem, orderLine, action.args),
+                        await promotionAction.execute(ctx, orderItem, orderLine, action.args, state),
                     );
                 }
             } else if (promotionAction instanceof PromotionOrderAction) {
                 if (this.isOrderArg(args)) {
                     const { order } = args;
-                    amount += Math.round(await promotionAction.execute(ctx, order, action.args));
+                    amount += Math.round(await promotionAction.execute(ctx, order, action.args, state));
                 }
             } else if (promotionAction instanceof PromotionShippingAction) {
                 if (this.isShippingArg(args)) {
                     const { shippingLine, order } = args;
                     amount += Math.round(
-                        await promotionAction.execute(ctx, shippingLine, order, action.args),
+                        await promotionAction.execute(ctx, shippingLine, order, action.args, state),
                     );
                 }
             }
@@ -149,7 +157,7 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
         }
     }
 
-    async test(ctx: RequestContext, order: Order): Promise<boolean> {
+    async test(ctx: RequestContext, order: Order): Promise<PromotionTestResult> {
         if (this.endsAt && this.endsAt < new Date()) {
             return false;
         }
@@ -159,13 +167,21 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
         if (this.couponCode && !order.couponCodes.includes(this.couponCode)) {
             return false;
         }
+        const promotionState: PromotionState = {};
         for (const condition of this.conditions) {
             const promotionCondition = this.allConditions[condition.code];
-            if (!promotionCondition || !(await promotionCondition.check(ctx, order, condition.args))) {
+            if (!promotionCondition) {
                 return false;
             }
+            const applicableOrConditionState = await promotionCondition.check(ctx, order, condition.args);
+            if (!applicableOrConditionState) {
+                return false;
+            }
+            if (typeof applicableOrConditionState === 'object') {
+                promotionState[condition.code] = applicableOrConditionState;
+            }
         }
-        return true;
+        return promotionState;
     }
     private isShippingAction(
         value: PromotionItemAction | PromotionOrderAction | PromotionShippingAction,

+ 1 - 0
packages/core/src/i18n/messages/en.json

@@ -10,6 +10,7 @@
     "cannot-transition-fulfillment-from-to": "Cannot transition Fulfillment from \"{ fromState }\" to \"{ toState }\"",
     "collection-id-or-slug-must-be-provided": "Either the Collection id or slug must be provided",
     "collection-id-slug-mismatch": "The provided id and slug refer to different Collections",
+    "conditions-required-for-action": "The PromotionAction '{ action }' requires the following conditions: { conditions }",
     "configurable-argument-is-required": "The argument '{ name }' is required, but the value is [{ value }]",
     "country-code-not-valid": "The countryCode \"{ countryCode }\" was not recognized",
     "customer-does-not-belong-to-customer-group": "Customer does not belong to this CustomerGroup",

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

@@ -183,7 +183,7 @@ export class OrderCalculator {
         for (const line of order.lines) {
             // Must be re-calculated for each line, since the previous lines may have triggered promotions
             // which affected the order price.
-            const applicablePromotions = await filterAsync(promotions, p => p.test(ctx, order));
+            const applicablePromotions = await filterAsync(promotions, p => p.test(ctx, order).then(Boolean));
 
             const lineHasExistingPromotions = !!line.items[0]?.adjustments?.find(
                 a => a.type === AdjustmentType.PROMOTION,
@@ -205,12 +205,14 @@ export class OrderCalculator {
                 // We need to test the promotion *again*, even though we've tested them for the line.
                 // This is because the previous Promotions may have adjusted the Order in such a way
                 // as to render later promotions no longer applicable.
-                if (await promotion.test(ctx, order)) {
+                const applicableOrState = await promotion.test(ctx, order);
+                if (applicableOrState) {
+                    const state = typeof applicableOrState === 'object' ? applicableOrState : undefined;
                     for (const item of line.items) {
                         const adjustment = await promotion.apply(ctx, {
                             orderItem: item,
                             orderLine: line,
-                        });
+                        }, state);
                         if (adjustment) {
                             item.addAdjustment(adjustment);
                             priceAdjusted = true;
@@ -283,13 +285,15 @@ export class OrderCalculator {
         }
 
         this.calculateOrderTotals(order);
-        const applicableOrderPromotions = await filterAsync(promotions, p => p.test(ctx, order));
+        const applicableOrderPromotions = await filterAsync(promotions, p => p.test(ctx, order).then(Boolean));
         if (applicableOrderPromotions.length) {
             for (const promotion of applicableOrderPromotions) {
                 // re-test the promotion on each iteration, since the order total
                 // may be modified by a previously-applied promotion
-                if (await promotion.test(ctx, order)) {
-                    const adjustment = await promotion.apply(ctx, { order });
+                const applicableOrState = await promotion.test(ctx, order);
+                if (applicableOrState) {
+                    const state = typeof applicableOrState === 'object' ? applicableOrState : undefined;
+                    const adjustment = await promotion.apply(ctx, { order }, state);
                     if (adjustment && adjustment.amount !== 0) {
                         const amount = adjustment.amount;
                         const weights = order.lines.map(l => l.proratedLinePriceWithTax);
@@ -330,15 +334,17 @@ export class OrderCalculator {
     }
 
     private async applyShippingPromotions(ctx: RequestContext, order: Order, promotions: Promotion[]) {
-        const applicableOrderPromotions = await filterAsync(promotions, p => p.test(ctx, order));
+        const applicableOrderPromotions = await filterAsync(promotions, p => p.test(ctx, order).then(Boolean));
         if (applicableOrderPromotions.length) {
             order.shippingLines.forEach(line => (line.adjustments = []));
             for (const promotion of applicableOrderPromotions) {
                 // re-test the promotion on each iteration, since the order total
                 // may be modified by a previously-applied promotion
-                if (await promotion.test(ctx, order)) {
+                const applicableOrState = await promotion.test(ctx, order);
+                if (applicableOrState) {
+                    const state = typeof applicableOrState === 'object' ? applicableOrState : undefined;
                     for (const shippingLine of order.shippingLines) {
-                        const adjustment = await promotion.apply(ctx, { shippingLine, order });
+                        const adjustment = await promotion.apply(ctx, { shippingLine, order }, state);
                         if (adjustment && adjustment.amount !== 0) {
                             shippingLine.addAdjustment(adjustment);
                         }

+ 33 - 2
packages/core/src/service/services/promotion.service.ts

@@ -1,6 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { ApplyCouponCodeResult } from '@vendure/common/lib/generated-shop-types';
 import {
+    ConfigurableOperation,
     ConfigurableOperationDefinition,
     CreatePromotionInput,
     CreatePromotionResult,
@@ -14,6 +15,7 @@ import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../api/common/request-context';
+import { UserInputError } from '../../common';
 import { ErrorResultUnion, JustErrorResults } from '../../common/error/error-result';
 import { MissingConditionsError } from '../../common/error/generated-graphql-admin-errors';
 import {
@@ -101,6 +103,11 @@ export class PromotionService {
         ctx: RequestContext,
         input: CreatePromotionInput,
     ): Promise<ErrorResultUnion<CreatePromotionResult, Promotion>> {
+        const conditions = input.conditions.map(c =>
+            this.configArgService.parseInput('PromotionCondition', c),
+        );
+        const actions = input.actions.map(a => this.configArgService.parseInput('PromotionAction', a));
+        this.validateRequiredConditions(conditions, actions);
         const promotion = new Promotion({
             name: input.name,
             enabled: input.enabled,
@@ -108,8 +115,8 @@ export class PromotionService {
             perCustomerUsageLimit: input.perCustomerUsageLimit,
             startsAt: input.startsAt,
             endsAt: input.endsAt,
-            conditions: input.conditions.map(c => this.configArgService.parseInput('PromotionCondition', c)),
-            actions: input.actions.map(a => this.configArgService.parseInput('PromotionAction', a)),
+            conditions,
+            actions,
             priorityScore: this.calculatePriorityScore(input),
         });
         if (promotion.conditions.length === 0 && !promotion.couponCode) {
@@ -228,4 +235,28 @@ export class PromotionService {
             where: { enabled: true },
         });
     }
+
+    private validateRequiredConditions(
+        conditions: ConfigurableOperation[],
+        actions: ConfigurableOperation[],
+    ) {
+        const conditionCodes: Record<string, string> = conditions.reduce(
+            (codeMap, { code }) => ({ ...codeMap, [code]: code }),
+            {},
+        );
+        for (const { code: actionCode } of actions) {
+            const actionDef = this.configArgService.getByCode('PromotionAction', actionCode);
+            const actionDependencies: PromotionCondition[] = actionDef.conditions || [];
+            if (!actionDependencies || actionDependencies.length === 0) {
+                continue;
+            }
+            const missingConditions = actionDependencies.filter(condition => !conditionCodes[condition.code]);
+            if (missingConditions.length) {
+                throw new UserInputError('error.conditions-required-for-action', {
+                    action: actionCode,
+                    conditions: missingConditions.map(c => c.code).join(', '),
+                });
+            }
+        }
+    }
 }