Browse Source

feat(core): add promotion state and promotion action-condition dependency

Karel Van De Winkel 4 years ago
parent
commit
dd66138ff0

+ 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>;
 }

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

@@ -0,0 +1,27 @@
+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 { 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: {
+        'buy_x_get_y_free': { required: true },
+    },
+    execute(ctx, orderItem, orderLine, args, state) {
+        const freeItemIds  = state.buy_x_get_y_free.freeItemIds as ID[];
+        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));
+}

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

@@ -0,0 +1,62 @@
+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,
 ];

+ 56 - 27
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,24 @@ 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 { PromotionConditionState } from './promotion-condition';
+
+
+export type ConditionConfig = {
+    [name: string]: { required?: boolean };
+}
+export type ConditionRequiredKeys<U extends ConditionConfig, value extends true | false> =
+    NonNullable<{ [K in keyof U]: value extends U[K]['required'] ? K : never }[keyof U]>;
+export type ConditionalState<U extends ConditionConfig> = {
+    [K in keyof U]: U[K]['required'] extends true ? PromotionConditionState : PromotionConditionState | undefined;
+}
+
 /**
  * @description
  * The function which is used by a PromotionItemAction to calculate the
@@ -20,11 +34,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 ConditionConfig> = (
     ctx: RequestContext,
     orderItem: OrderItem,
     orderLine: OrderLine,
     args: ConfigArgValues<T>,
+    state: ConditionalState<U>,
 ) => number | Promise<number>;
 
 /**
@@ -35,10 +50,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 ConditionConfig> = (
     ctx: RequestContext,
     order: Order,
     args: ConfigArgValues<T>,
+    state: ConditionalState<U>,
 ) => number | Promise<number>;
 
 /**
@@ -49,15 +65,19 @@ 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 ConditionConfig> = (
     ctx: RequestContext,
     shippingLine: ShippingLine,
     order: Order,
     args: ConfigArgValues<T>,
+    state: ConditionalState<U>,
 ) => number | Promise<number>;
 
-export interface PromotionActionConfig<T extends ConfigArgs> extends ConfigurableOperationDefOptions<T> {
+
+export interface PromotionActionConfig<T extends ConfigArgs, U extends ConditionConfig>
+    extends ConfigurableOperationDefOptions<T> {
     priorityValue?: number;
+    conditions?: U;
 }
 
 /**
@@ -67,12 +87,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 ConditionConfig>
+    extends PromotionActionConfig<T, U> {
     /**
      * @description
      * The function which contains the promotion calculation logic.
      */
-    execute: ExecutePromotionItemActionFn<T>;
+    execute: ExecutePromotionItemActionFn<T, U>;
 }
 
 /**
@@ -81,12 +102,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 ConditionConfig>
+    extends PromotionActionConfig<T, U> {
     /**
      * @description
      * The function which contains the promotion calculation logic.
      */
-    execute: ExecutePromotionOrderActionFn<T>;
+    execute: ExecutePromotionOrderActionFn<T, U>;
 }
 
 /**
@@ -95,12 +117,13 @@ 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 ConditionConfig>
+    extends PromotionActionConfig<T, U> {
     /**
      * @description
      * The function which contains the promotion calculation logic.
      */
-    execute: ExecutePromotionShippingActionFn<T>;
+    execute: ExecutePromotionShippingActionFn<T, U>;
 }
 
 /**
@@ -111,7 +134,7 @@ export interface PromotionShippingActionConfig<T extends ConfigArgs> extends Pro
  * @docsPage promotion-action
  * @docsWeight 0
  */
-export abstract class PromotionAction<T extends ConfigArgs = {}> extends ConfigurableOperationDef<T> {
+export abstract class PromotionAction<T extends ConfigArgs = {}, U extends ConditionConfig = {}> extends ConfigurableOperationDef<T> {
     /**
      * @description
      * Used to determine the order of application of multiple Promotions
@@ -121,10 +144,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 +174,18 @@ 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 ConditionConfig = {}>
+    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, Object.keys(this.conditions)) : {};
+        return this.executeFn(ctx, orderItem, orderLine, this.argsArrayToHash(args), actionState as ConditionalState<U>);
     }
 }
 
@@ -183,16 +210,17 @@ 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 ConditionConfig = {}> 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 ConditionalState<U>);
     }
 }
 
@@ -204,15 +232,16 @@ 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 ConditionConfig = {}> 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 ConditionalState<U>);
     }
 }

+ 6 - 4
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,9 +7,10 @@ import {
     ConfigurableOperationDef,
     ConfigurableOperationDefOptions,
 } from '../../common/configurable-operation';
-import { OrderLine } from '../../entity';
 import { Order } from '../../entity/order/order.entity';
 
+export type CheckPromotionConditionResult = boolean | PromotionConditionState;
+
 /**
  * @description
  * A function which checks whether or not a given {@link Order} satisfies the {@link PromotionCondition}.
@@ -22,7 +22,7 @@ export type CheckPromotionConditionFn<T extends ConfigArgs> = (
     ctx: RequestContext,
     order: Order,
     args: ConfigArgValues<T>,
-) => boolean | Promise<boolean>;
+) => CheckPromotionConditionResult | Promise<CheckPromotionConditionResult>;
 
 /**
  * @description
@@ -37,6 +37,8 @@ export interface PromotionConditionConfig<T extends ConfigArgs> extends Configur
     priorityValue?: number;
 }
 
+export type PromotionConditionState = Record<string, unknown>;
+
 /**
  * @description
  * PromotionConditions are used to create {@link Promotion}s. The purpose of a PromotionCondition
@@ -65,7 +67,7 @@ export class PromotionCondition<T extends ConfigArgs = ConfigArgs> extends Confi
         this.priorityValue = config.priorityValue || 0;
     }
 
-    async check(ctx: RequestContext, order: Order, args: ConfigArg[]): Promise<boolean> {
+    async check(ctx: RequestContext, order: Order, args: ConfigArg[]): Promise<CheckPromotionConditionResult> {
         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,

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

+ 27 - 3
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 {
@@ -25,7 +27,7 @@ import { AdjustmentSource } from '../../common/types/adjustment-source';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
-import { PromotionAction } from '../../config/promotion/promotion-action';
+import { ConditionConfig, PromotionAction } from '../../config/promotion/promotion-action';
 import { PromotionCondition } from '../../config/promotion/promotion-condition';
 import { Order } from '../../entity/order/order.entity';
 import { Promotion } from '../../entity/promotion/promotion.entity';
@@ -101,6 +103,9 @@ 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 +113,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 +233,23 @@ 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 { conditions: conditionConfig } =
+                this.configArgService.getByCode('PromotionAction', actionCode) as { conditions?: ConditionConfig };
+            if (!conditionConfig) {
+                continue;
+            }
+            const missingConditions = Object.keys(conditionConfig)
+                .filter((conditionCode) => conditionConfig[conditionCode].required && !conditionCodes[conditionCode]);
+            if (missingConditions.length) {
+                throw new UserInputError('error.conditions-required-for-action', {
+                    name: actionCode,
+                    value: String(missingConditions),
+                });
+            }
+        }
+    }
 }