Просмотр исходного кода

feat(core): Create PromotionLineAction (#2971)

Closes #2956
Tiis 1 год назад
Родитель
Сommit
0ff82885b6

+ 7 - 0
packages/core/e2e/fixtures/test-money-strategy.ts

@@ -0,0 +1,7 @@
+import { DefaultMoneyStrategy } from '@vendure/core';
+
+export class TestMoneyStrategy extends DefaultMoneyStrategy {
+    round(value: number, quantity = 1): number {
+        return Math.round(value * quantity);
+    }
+}

+ 77 - 30
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -28,7 +28,9 @@ import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 import { freeShipping } from '../src/config/promotion/actions/free-shipping-action';
 import { orderFixedDiscount } from '../src/config/promotion/actions/order-fixed-discount-action';
+import { orderLineFixedDiscount } from '../src/config/promotion/actions/order-line-fixed-discount-action';
 
+import { TestMoneyStrategy } from './fixtures/test-money-strategy';
 import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import { CurrencyCode, HistoryEntryType, LanguageCode } from './graphql/generated-e2e-admin-types';
 import * as Codegen from './graphql/generated-e2e-admin-types';
@@ -66,6 +68,9 @@ describe('Promotions applied to Orders', () => {
             paymentOptions: {
                 paymentMethodHandlers: [testSuccessfulPaymentMethod],
             },
+            entityOptions: {
+                moneyStrategy: new TestMoneyStrategy(),
+            },
         }),
     );
 
@@ -834,6 +839,58 @@ describe('Promotions applied to Orders', () => {
             });
         });
 
+        describe('orderLineFixedDiscount', () => {
+            const couponCode = '1000_off_order_line';
+            let promotion: Codegen.PromotionFragment;
+
+            beforeAll(async () => {
+                promotion = await createPromotion({
+                    enabled: true,
+                    name: '$1000 discount on order line',
+                    couponCode,
+                    conditions: [],
+                    actions: [
+                        {
+                            code: orderLineFixedDiscount.code,
+                            arguments: [{ name: 'discount', value: '1000' }],
+                        },
+                    ],
+                });
+            });
+
+            afterAll(async () => {
+                await deletePromotion(promotion.id);
+            });
+
+            it('prices exclude tax', async () => {
+                await shopClient.asAnonymousUser();
+                const { addItemToOrder } = await shopClient.query<
+                    CodegenShop.AddItemToOrderMutation,
+                    CodegenShop.AddItemToOrderMutationVariables
+                >(ADD_ITEM_TO_ORDER, {
+                    productVariantId: getVariantBySlug('item-1000').id,
+                    quantity: 3,
+                });
+                orderResultGuard.assertSuccess(addItemToOrder);
+                expect(addItemToOrder.discounts.length).toBe(0);
+                expect(addItemToOrder.lines[0].discounts.length).toBe(0);
+                expect(addItemToOrder.total).toBe(3000);
+                expect(addItemToOrder.totalWithTax).toBe(3600);
+
+                const { applyCouponCode } = await shopClient.query<
+                    CodegenShop.ApplyCouponCodeMutation,
+                    CodegenShop.ApplyCouponCodeMutationVariables
+                >(APPLY_COUPON_CODE, {
+                    couponCode,
+                });
+                orderResultGuard.assertSuccess(applyCouponCode);
+
+                expect(applyCouponCode.total).toBe(2000);
+                expect(applyCouponCode.totalWithTax).toBe(2400);
+                expect(applyCouponCode.lines[0].discounts.length).toBe(1);
+            });
+        });
+
         describe('discountOnItemWithFacets', () => {
             const couponCode = '50%_off_sale_items';
             let promotion: Codegen.PromotionFragment;
@@ -925,9 +982,8 @@ describe('Promotions applied to Orders', () => {
                 expect(removeCouponCode!.total).toBe(2200);
                 expect(removeCouponCode!.totalWithTax).toBe(2640);
 
-                const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
-                    GET_ACTIVE_ORDER,
-                );
+                const { activeOrder } =
+                    await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
                 expect(getItemSale1Line(activeOrder!.lines).discounts.length).toBe(0);
                 expect(activeOrder!.total).toBe(2200);
                 expect(activeOrder!.totalWithTax).toBe(2640);
@@ -986,9 +1042,8 @@ describe('Promotions applied to Orders', () => {
                 expect(removeCouponCode!.total).toBe(2200);
                 expect(removeCouponCode!.totalWithTax).toBe(2640);
 
-                const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
-                    GET_ACTIVE_ORDER,
-                );
+                const { activeOrder } =
+                    await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
                 expect(getItemSale1Line(activeOrder!.lines).discounts.length).toBe(0);
                 expect(activeOrder!.total).toBe(2200);
                 expect(activeOrder!.totalWithTax).toBe(2640);
@@ -1534,9 +1589,8 @@ describe('Promotions applied to Orders', () => {
 
                 await addGuestCustomerToOrder();
 
-                const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
-                    GET_ACTIVE_ORDER,
-                );
+                const { activeOrder } =
+                    await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
                 expect(activeOrder!.couponCodes).toEqual([]);
                 expect(activeOrder!.totalWithTax).toBe(6000);
             });
@@ -1627,9 +1681,8 @@ describe('Promotions applied to Orders', () => {
 
                 await logInAsRegisteredCustomer();
 
-                const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
-                    GET_ACTIVE_ORDER,
-                );
+                const { activeOrder } =
+                    await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
                 expect(activeOrder!.totalWithTax).toBe(6000);
                 expect(activeOrder!.couponCodes).toEqual([]);
             });
@@ -1883,9 +1936,8 @@ describe('Promotions applied to Orders', () => {
         expect(addItemToOrder.discounts.length).toBe(1);
         expect(addItemToOrder.discounts[0].description).toBe('Test Promo');
 
-        const { activeOrder: check1 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
-            GET_ACTIVE_ORDER,
-        );
+        const { activeOrder: check1 } =
+            await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
         expect(check1!.discounts.length).toBe(1);
         expect(check1!.discounts[0].description).toBe('Test Promo');
 
@@ -1899,9 +1951,8 @@ describe('Promotions applied to Orders', () => {
         orderResultGuard.assertSuccess(removeOrderLine);
         expect(removeOrderLine.discounts.length).toBe(0);
 
-        const { activeOrder: check2 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
-            GET_ACTIVE_ORDER,
-        );
+        const { activeOrder: check2 } =
+            await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
         expect(check2!.discounts.length).toBe(0);
     });
 
@@ -2043,9 +2094,8 @@ describe('Promotions applied to Orders', () => {
                 quantity: 1,
             });
 
-            const { activeOrder: check1 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
-                GET_ACTIVE_ORDER,
-            );
+            const { activeOrder: check1 } =
+                await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
 
             expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0);
             expect(check1!.totalWithTax).toBe(0);
@@ -2055,9 +2105,8 @@ describe('Promotions applied to Orders', () => {
                 CodegenShop.ApplyCouponCodeMutationVariables
             >(APPLY_COUPON_CODE, { couponCode: couponCode2 });
 
-            const { activeOrder: check2 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
-                GET_ACTIVE_ORDER,
-            );
+            const { activeOrder: check2 } =
+                await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
             expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0);
             expect(check2!.totalWithTax).toBe(0);
         });
@@ -2080,9 +2129,8 @@ describe('Promotions applied to Orders', () => {
                 quantity: 1,
             });
 
-            const { activeOrder: check1 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
-                GET_ACTIVE_ORDER,
-            );
+            const { activeOrder: check1 } =
+                await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
 
             expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0);
             expect(check1!.totalWithTax).toBe(0);
@@ -2092,9 +2140,8 @@ describe('Promotions applied to Orders', () => {
                 CodegenShop.ApplyCouponCodeMutationVariables
             >(APPLY_COUPON_CODE, { couponCode: couponCode2 });
 
-            const { activeOrder: check2 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
-                GET_ACTIVE_ORDER,
-            );
+            const { activeOrder: check2 } =
+                await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
             expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0);
             expect(check2!.totalWithTax).toBe(0);
         });

+ 19 - 0
packages/core/src/config/promotion/actions/order-line-fixed-discount-action.ts

@@ -0,0 +1,19 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+
+import { PromotionLineAction } from '../promotion-action';
+
+export const orderLineFixedDiscount = new PromotionLineAction({
+    code: 'order_line_fixed_discount',
+    args: {
+        discount: {
+            type: 'int',
+            ui: {
+                component: 'currency-form-input',
+            },
+        },
+    },
+    execute(ctx, orderLine, args) {
+        return -args.discount;
+    },
+    description: [{ languageCode: LanguageCode.en, value: 'Discount orderLine by fixed amount' }],
+});

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

@@ -2,6 +2,7 @@ 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 { orderLineFixedDiscount } from './actions/order-line-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';
@@ -27,6 +28,7 @@ export * from './utils/facet-value-checker';
 
 export const defaultPromotionActions = [
     orderFixedDiscount,
+    orderLineFixedDiscount,
     orderPercentageDiscount,
     discountOnItemWithFacets,
     productsPercentageDiscount,

+ 90 - 1
packages/core/src/config/promotion/promotion-action.ts

@@ -64,7 +64,7 @@ export type ConditionState<
 /**
  * @description
  * The function which is used by a PromotionItemAction to calculate the
- * discount on the OrderLine.
+ * discount on the OrderLine for each item.
  *
  * @docsCategory promotions
  * @docsPage promotion-action
@@ -77,6 +77,22 @@ export type ExecutePromotionItemActionFn<T extends ConfigArgs, U extends Array<P
     promotion: Promotion,
 ) => number | Promise<number>;
 
+/**
+ * @description
+ * The function which is used by a PromotionLineAction to calculate the
+ * discount on the OrderLine.
+ *
+ * @docsCategory promotions
+ * @docsPage promotion-action
+ */
+export type ExecutePromotionLineActionFn<T extends ConfigArgs, U extends Array<PromotionCondition<any>>> = (
+    ctx: RequestContext,
+    orderLine: OrderLine,
+    args: ConfigArgValues<T>,
+    state: ConditionState<U>,
+    promotion: Promotion,
+) => number | Promise<number>;
+
 /**
  * @description
  * The function which is used by a PromotionOrderAction to calculate the
@@ -201,6 +217,24 @@ export interface PromotionItemActionConfig<T extends ConfigArgs, U extends Promo
     execute: ExecutePromotionItemActionFn<T, U>;
 }
 
+/**
+ * @description
+ * Configuration for a {@link PromotionLineAction}
+ *
+ * @docsCategory promotions
+ * @docsPage promotion-action
+ */
+export interface PromotionLineActionConfig<T extends ConfigArgs, U extends PromotionCondition[]>
+    extends PromotionActionConfig<T, U> {
+    /**
+     * @description
+     * The function which contains the promotion calculation logic.
+     * Should resolve to a number which represents the amount by which to discount
+     * the OrderLine, i.e. the number should be negative.
+     */
+    execute: ExecutePromotionLineActionFn<T, U>;
+}
+
 /**
  * @description
  *
@@ -351,6 +385,61 @@ export class PromotionItemAction<
     }
 }
 
+/**
+ * @description
+ * Represents a PromotionAction which applies to individual {@link OrderLine}s.
+ * The difference from PromotionItemAction is that it applies regardless of the Quantity of the OrderLine.
+ *
+ * @example
+ * ```ts
+ * // Applies a percentage discount to each OrderLine
+ * const linePercentageDiscount = new PromotionLineAction({
+ *     code: 'line_percentage_discount',
+ *     args: { discount: 'percentage' },
+ *     execute(ctx, orderLine, args) {
+ *         return -orderLine.linePrice * (args.discount / 100);
+ *     },
+ *     description: 'Discount every line by { discount }%',
+ * });
+ * ```
+ *
+ * @docsCategory promotions
+ * @docsPage promotion-action
+ */
+export class PromotionLineAction<
+    T extends ConfigArgs = ConfigArgs,
+    U extends Array<PromotionCondition<any>> = [],
+> extends PromotionAction<T, U> {
+    private readonly executeFn: ExecutePromotionLineActionFn<T, U>;
+    constructor(config: PromotionLineActionConfig<T, U>) {
+        super(config);
+        this.executeFn = config.execute;
+    }
+
+    /** @internal */
+    execute(
+        ctx: RequestContext,
+        orderLine: OrderLine,
+        args: ConfigArg[],
+        state: PromotionState,
+        promotion: Promotion,
+    ) {
+        const actionState = this.conditions
+            ? pick(
+                  state,
+                  this.conditions.map(c => c.code),
+              )
+            : {};
+        return this.executeFn(
+            ctx,
+            orderLine,
+            this.argsArrayToHash(args),
+            actionState as ConditionState<U>,
+            promotion,
+        );
+    }
+}
+
 /**
  * @description
  * Represents a PromotionAction which applies to the {@link Order} as a whole.

+ 25 - 2
packages/core/src/entity/promotion/promotion.entity.ts

@@ -12,6 +12,7 @@ import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import {
     PromotionAction,
     PromotionItemAction,
+    PromotionLineAction,
     PromotionOrderAction,
     PromotionShippingAction,
 } from '../../config/promotion/promotion-action';
@@ -28,6 +29,10 @@ export interface ApplyOrderItemActionArgs {
     orderLine: OrderLine;
 }
 
+export interface ApplyOrderLineActionArgs {
+    orderLine: OrderLine;
+}
+
 export interface ApplyOrderActionArgs {
     order: Order;
 }
@@ -49,7 +54,7 @@ export type PromotionTestResult = boolean | PromotionState;
  * will be applied to an Order.
  *
  * Each assigned {@link PromotionCondition} is checked against the Order, and if they all return `true`,
- * then each assign {@link PromotionItemAction} / {@link PromotionOrderAction} is applied to the Order.
+ * then each assign {@link PromotionItemAction} / {@link PromotionLineAction} / {@link PromotionOrderAction} / {@link PromotionShippingAction} is applied to the Order.
  *
  * @docsCategory entities
  */
@@ -61,7 +66,11 @@ export class Promotion
     type = AdjustmentType.PROMOTION;
     private readonly allConditions: { [code: string]: PromotionCondition } = {};
     private readonly allActions: {
-        [code: string]: PromotionItemAction | PromotionOrderAction | PromotionShippingAction;
+        [code: string]:
+            | PromotionItemAction
+            | PromotionLineAction
+            | PromotionOrderAction
+            | PromotionShippingAction;
     } = {};
 
     constructor(
@@ -149,6 +158,14 @@ export class Promotion
             const promotionAction = this.allActions[action.code];
             if (promotionAction instanceof PromotionItemAction) {
                 if (this.isOrderItemArg(args)) {
+                    const { orderLine } = args;
+                    amount += roundMoney(
+                        await promotionAction.execute(ctx, orderLine, action.args, state, this),
+                        orderLine.quantity,
+                    );
+                }
+            } else if (promotionAction instanceof PromotionLineAction) {
+                if (this.isOrderLineArg(args)) {
                     const { orderLine } = args;
                     amount += roundMoney(
                         await promotionAction.execute(ctx, orderLine, action.args, state, this),
@@ -237,6 +254,12 @@ export class Promotion
         return !this.isOrderItemArg(value) && !this.isShippingArg(value);
     }
 
+    private isOrderLineArg(
+        value: ApplyOrderLineActionArgs | ApplyOrderActionArgs | ApplyShippingActionArgs,
+    ): value is ApplyOrderLineActionArgs {
+        return value.hasOwnProperty('orderLine');
+    }
+
     private isOrderItemArg(
         value: ApplyOrderItemActionArgs | ApplyOrderActionArgs | ApplyShippingActionArgs,
     ): value is ApplyOrderItemActionArgs {

+ 2 - 1
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -176,6 +176,8 @@ export class OrderCalculator {
      * Applies promotions to OrderItems. This is a quite complex function, due to the inherent complexity
      * of applying the promotions, and also due to added complexity in the name of performance
      * optimization. Therefore, it is heavily annotated so that the purpose of each step is clear.
+     * Additionally, this is used in both promotionItemAction and promotionLineAction,
+     * as it is difficult to separate action types at this stage.
      */
     private async applyOrderItemPromotions(
         ctx: RequestContext,
@@ -199,7 +201,6 @@ export class OrderCalculator {
                     // for (const item of line.items) {
                     const adjustment = await promotion.apply(ctx, { orderLine: line }, state);
                     if (adjustment) {
-                        adjustment.amount = adjustment.amount * line.quantity;
                         line.addAdjustment(adjustment);
                         priceAdjusted = true;
                     }