Ver código fonte

feat(core): Implement Shipping promotion actions

Relates to #580. This commit introduces the new PromotionShippingAction, allowing the creation
of promotions which discount the shipping price specifically.
Michael Bromley 5 anos atrás
pai
commit
69b12e30dc
23 arquivos alterados com 472 adições e 99 exclusões
  1. 15 13
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 1 1
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  3. 14 12
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  4. 2 0
      packages/common/src/generated-shop-types.ts
  5. 15 13
      packages/common/src/generated-types.ts
  6. 14 12
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  7. 13 1
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  8. 4 1
      packages/core/e2e/graphql/shop-definitions.ts
  9. 65 0
      packages/core/e2e/order-promotion.e2e-spec.ts
  10. 2 0
      packages/core/src/api/schema/common/order.type.graphql
  11. 0 0
      packages/core/src/config/promotion/actions/facet-values-percentage-discount-action.ts
  12. 12 0
      packages/core/src/config/promotion/actions/free-shipping-action.ts
  13. 0 0
      packages/core/src/config/promotion/actions/product-percentage-discount-action.ts
  14. 6 4
      packages/core/src/config/promotion/index.ts
  15. 51 0
      packages/core/src/config/promotion/promotion-action.ts
  16. 10 0
      packages/core/src/entity/order/order.entity.ts
  17. 37 8
      packages/core/src/entity/promotion/promotion.entity.ts
  18. 26 2
      packages/core/src/entity/shipping-line/shipping-line.entity.ts
  19. 149 18
      packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts
  20. 22 2
      packages/core/src/service/helpers/order-calculator/order-calculator.ts
  21. 14 12
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  22. 0 0
      schema-admin.json
  23. 0 0
      schema-shop.json

+ 15 - 13
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1287,6 +1287,19 @@ export type UpdateFacetValueInput = {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type Fulfillment = Node & {
+  __typename?: 'Fulfillment';
+  nextStates: Array<Scalars['String']>;
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  orderItems: Array<OrderItem>;
+  state: Scalars['String'];
+  method: Scalars['String'];
+  trackingCode?: Maybe<Scalars['String']>;
+  customFields?: Maybe<Scalars['JSON']>;
+};
+
 export type UpdateGlobalSettingsInput = {
   availableLanguages?: Maybe<Array<LanguageCode>>;
   trackInventory?: Maybe<Scalars['Boolean']>;
@@ -1462,19 +1475,6 @@ export type OrderHistoryArgs = {
   options?: Maybe<HistoryEntryListOptions>;
 };
 
-export type Fulfillment = Node & {
-  __typename?: 'Fulfillment';
-  nextStates: Array<Scalars['String']>;
-  id: Scalars['ID'];
-  createdAt: Scalars['DateTime'];
-  updatedAt: Scalars['DateTime'];
-  orderItems: Array<OrderItem>;
-  state: Scalars['String'];
-  method: Scalars['String'];
-  trackingCode?: Maybe<Scalars['String']>;
-  customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type UpdateOrderInput = {
   id: Scalars['ID'];
   customFields?: Maybe<Scalars['JSON']>;
@@ -3567,6 +3567,8 @@ export type ShippingLine = {
   shippingMethod: ShippingMethod;
   price: Scalars['Int'];
   priceWithTax: Scalars['Int'];
+  discountedPrice: Scalars['Int'];
+  discountedPriceWithTax: Scalars['Int'];
   discounts: Array<Adjustment>;
 };
 

+ 1 - 1
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -81,10 +81,10 @@ const result: PossibleTypesResultData = {
             'Collection',
             'Customer',
             'Facet',
+            'Fulfillment',
             'HistoryEntry',
             'Job',
             'Order',
-            'Fulfillment',
             'PaymentMethod',
             'Product',
             'ProductVariant',

+ 14 - 12
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -1111,6 +1111,18 @@ export type UpdateFacetValueInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type Fulfillment = Node & {
+    nextStates: Array<Scalars['String']>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    orderItems: Array<OrderItem>;
+    state: Scalars['String'];
+    method: Scalars['String'];
+    trackingCode?: Maybe<Scalars['String']>;
+    customFields?: Maybe<Scalars['JSON']>;
+};
+
 export type UpdateGlobalSettingsInput = {
     availableLanguages?: Maybe<Array<LanguageCode>>;
     trackInventory?: Maybe<Scalars['Boolean']>;
@@ -1274,18 +1286,6 @@ export type OrderHistoryArgs = {
     options?: Maybe<HistoryEntryListOptions>;
 };
 
-export type Fulfillment = Node & {
-    nextStates: Array<Scalars['String']>;
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    orderItems: Array<OrderItem>;
-    state: Scalars['String'];
-    method: Scalars['String'];
-    trackingCode?: Maybe<Scalars['String']>;
-    customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type UpdateOrderInput = {
     id: Scalars['ID'];
     customFields?: Maybe<Scalars['JSON']>;
@@ -3329,6 +3329,8 @@ export type ShippingLine = {
     shippingMethod: ShippingMethod;
     price: Scalars['Int'];
     priceWithTax: Scalars['Int'];
+    discountedPrice: Scalars['Int'];
+    discountedPriceWithTax: Scalars['Int'];
     discounts: Array<Adjustment>;
 };
 

+ 2 - 0
packages/common/src/generated-shop-types.ts

@@ -1808,6 +1808,8 @@ export type ShippingLine = {
     shippingMethod: ShippingMethod;
     price: Scalars['Int'];
     priceWithTax: Scalars['Int'];
+    discountedPrice: Scalars['Int'];
+    discountedPriceWithTax: Scalars['Int'];
     discounts: Array<Adjustment>;
 };
 

+ 15 - 13
packages/common/src/generated-types.ts

@@ -1256,6 +1256,19 @@ export type UpdateFacetValueInput = {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type Fulfillment = Node & {
+  __typename?: 'Fulfillment';
+  nextStates: Array<Scalars['String']>;
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  orderItems: Array<OrderItem>;
+  state: Scalars['String'];
+  method: Scalars['String'];
+  trackingCode?: Maybe<Scalars['String']>;
+  customFields?: Maybe<Scalars['JSON']>;
+};
+
 export type UpdateGlobalSettingsInput = {
   availableLanguages?: Maybe<Array<LanguageCode>>;
   trackInventory?: Maybe<Scalars['Boolean']>;
@@ -1431,19 +1444,6 @@ export type OrderHistoryArgs = {
   options?: Maybe<HistoryEntryListOptions>;
 };
 
-export type Fulfillment = Node & {
-  __typename?: 'Fulfillment';
-  nextStates: Array<Scalars['String']>;
-  id: Scalars['ID'];
-  createdAt: Scalars['DateTime'];
-  updatedAt: Scalars['DateTime'];
-  orderItems: Array<OrderItem>;
-  state: Scalars['String'];
-  method: Scalars['String'];
-  trackingCode?: Maybe<Scalars['String']>;
-  customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type UpdateOrderInput = {
   id: Scalars['ID'];
   customFields?: Maybe<Scalars['JSON']>;
@@ -3535,6 +3535,8 @@ export type ShippingLine = {
   shippingMethod: ShippingMethod;
   price: Scalars['Int'];
   priceWithTax: Scalars['Int'];
+  discountedPrice: Scalars['Int'];
+  discountedPriceWithTax: Scalars['Int'];
   discounts: Array<Adjustment>;
 };
 

+ 14 - 12
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1111,6 +1111,18 @@ export type UpdateFacetValueInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type Fulfillment = Node & {
+    nextStates: Array<Scalars['String']>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    orderItems: Array<OrderItem>;
+    state: Scalars['String'];
+    method: Scalars['String'];
+    trackingCode?: Maybe<Scalars['String']>;
+    customFields?: Maybe<Scalars['JSON']>;
+};
+
 export type UpdateGlobalSettingsInput = {
     availableLanguages?: Maybe<Array<LanguageCode>>;
     trackInventory?: Maybe<Scalars['Boolean']>;
@@ -1274,18 +1286,6 @@ export type OrderHistoryArgs = {
     options?: Maybe<HistoryEntryListOptions>;
 };
 
-export type Fulfillment = Node & {
-    nextStates: Array<Scalars['String']>;
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    orderItems: Array<OrderItem>;
-    state: Scalars['String'];
-    method: Scalars['String'];
-    trackingCode?: Maybe<Scalars['String']>;
-    customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type UpdateOrderInput = {
     id: Scalars['ID'];
     customFields?: Maybe<Scalars['JSON']>;
@@ -3329,6 +3329,8 @@ export type ShippingLine = {
     shippingMethod: ShippingMethod;
     price: Scalars['Int'];
     priceWithTax: Scalars['Int'];
+    discountedPrice: Scalars['Int'];
+    discountedPriceWithTax: Scalars['Int'];
     discounts: Array<Adjustment>;
 };
 

+ 13 - 1
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1752,6 +1752,8 @@ export type ShippingLine = {
     shippingMethod: ShippingMethod;
     price: Scalars['Int'];
     priceWithTax: Scalars['Int'];
+    discountedPrice: Scalars['Int'];
+    discountedPriceWithTax: Scalars['Int'];
     discounts: Array<Adjustment>;
 };
 
@@ -2594,7 +2596,17 @@ export type NativeAuthInput = {
 
 export type TestOrderFragmentFragment = Pick<
     Order,
-    'id' | 'code' | 'state' | 'active' | 'total' | 'totalWithTax' | 'couponCodes' | 'shipping'
+    | 'id'
+    | 'code'
+    | 'state'
+    | 'active'
+    | 'subTotal'
+    | 'subTotalWithTax'
+    | 'shipping'
+    | 'shippingWithTax'
+    | 'total'
+    | 'totalWithTax'
+    | 'couponCodes'
 > & {
     discounts: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
     lines: Array<

+ 4 - 1
packages/core/e2e/graphql/shop-definitions.ts

@@ -6,6 +6,10 @@ export const TEST_ORDER_FRAGMENT = gql`
         code
         state
         active
+        subTotal
+        subTotalWithTax
+        shipping
+        shippingWithTax
         total
         totalWithTax
         couponCodes
@@ -30,7 +34,6 @@ export const TEST_ORDER_FRAGMENT = gql`
                 type
             }
         }
-        shipping
         shippingLines {
             shippingMethod {
                 id

+ 65 - 0
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -21,6 +21,7 @@ import path from 'path';
 
 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 { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
@@ -49,6 +50,7 @@ import {
     GetOrderPromotionsByCode,
     RemoveCouponCode,
     SetCustomerForOrder,
+    SetShippingMethod,
     TestOrderFragmentFragment,
     TestOrderWithPaymentsFragment,
     UpdatedOrderFragment,
@@ -70,6 +72,7 @@ import {
     GET_ORDER_PROMOTIONS_BY_CODE,
     REMOVE_COUPON_CODE,
     SET_CUSTOMER,
+    SET_SHIPPING_METHOD,
 } from './graphql/shop-definitions';
 import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
 
@@ -904,6 +907,68 @@ describe('Promotions applied to Orders', () => {
             });
         });
 
+        describe('freeShipping', () => {
+            const couponCode = 'FREE_SHIPPING';
+            let promotion: PromotionFragment;
+
+            beforeAll(async () => {
+                promotion = await createPromotion({
+                    enabled: true,
+                    name: 'Free shipping',
+                    couponCode,
+                    conditions: [],
+                    actions: [
+                        {
+                            code: freeShipping.code,
+                            arguments: [],
+                        },
+                    ],
+                });
+            });
+
+            afterAll(async () => {
+                await deletePromotion(promotion.id);
+                shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            });
+
+            it('prices exclude tax', async () => {
+                const { addItemToOrder } = await shopClient.query<
+                    AddItemToOrder.Mutation,
+                    AddItemToOrder.Variables
+                >(ADD_ITEM_TO_ORDER, {
+                    productVariantId: getVariantBySlug('item-5000').id,
+                    quantity: 1,
+                });
+                const { setOrderShippingMethod } = await shopClient.query<
+                    SetShippingMethod.Mutation,
+                    SetShippingMethod.Variables
+                >(SET_SHIPPING_METHOD, {
+                    id: 'T_1',
+                });
+                orderResultGuard.assertSuccess(setOrderShippingMethod);
+                expect(setOrderShippingMethod.discounts).toEqual([]);
+                expect(setOrderShippingMethod.shipping).toBe(500);
+                expect(setOrderShippingMethod.shippingWithTax).toBe(500);
+                expect(setOrderShippingMethod.total).toBe(5500);
+                expect(setOrderShippingMethod.totalWithTax).toBe(6500);
+
+                const { applyCouponCode } = await shopClient.query<
+                    ApplyCouponCode.Mutation,
+                    ApplyCouponCode.Variables
+                >(APPLY_COUPON_CODE, {
+                    couponCode,
+                });
+                orderResultGuard.assertSuccess(applyCouponCode);
+
+                expect(applyCouponCode.discounts.length).toBe(1);
+                expect(applyCouponCode.discounts[0].description).toBe('Free shipping');
+                expect(applyCouponCode.shipping).toBe(0);
+                expect(applyCouponCode.shippingWithTax).toBe(0);
+                expect(applyCouponCode.total).toBe(5000);
+                expect(applyCouponCode.totalWithTax).toBe(6000);
+            });
+        });
+
         describe('multiple promotions simultaneously', () => {
             const saleItem50pcOffCoupon = 'CODE1';
             const order15pcOffCoupon = 'CODE2';

+ 2 - 0
packages/core/src/api/schema/common/order.type.graphql

@@ -98,6 +98,8 @@ type ShippingLine {
     shippingMethod: ShippingMethod!
     price: Int!
     priceWithTax: Int!
+    discountedPrice: Int!
+    discountedPriceWithTax: Int!
     discounts: [Adjustment!]!
 }
 

+ 0 - 0
packages/core/src/config/promotion/actions/facet-values-discount-action.ts → packages/core/src/config/promotion/actions/facet-values-percentage-discount-action.ts


+ 12 - 0
packages/core/src/config/promotion/actions/free-shipping-action.ts

@@ -0,0 +1,12 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+
+import { PromotionShippingAction } from '../promotion-action';
+
+export const freeShipping = new PromotionShippingAction({
+    code: 'free_shipping',
+    args: {},
+    execute(ctx, shippingLine, order, args) {
+        return -shippingLine.priceWithTax;
+    },
+    description: [{ languageCode: LanguageCode.en, value: 'Free shipping' }],
+});

+ 0 - 0
packages/core/src/config/promotion/actions/product-discount-action.ts → packages/core/src/config/promotion/actions/product-percentage-discount-action.ts


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

@@ -1,7 +1,8 @@
-import { discountOnItemWithFacets } from './actions/facet-values-discount-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-discount-action';
+import { productsPercentageDiscount } from './actions/product-percentage-discount-action';
 import { containsProducts } from './conditions/contains-products-condition';
 import { customerGroup } from './conditions/customer-group-condition';
 import { hasFacetValues } from './conditions/has-facet-values-condition';
@@ -9,9 +10,9 @@ import { minimumOrderAmount } from './conditions/min-order-amount-condition';
 
 export * from './promotion-action';
 export * from './promotion-condition';
-export * from './actions/facet-values-discount-action';
+export * from './actions/facet-values-percentage-discount-action';
 export * from './actions/order-percentage-discount-action';
-export * from './actions/product-discount-action';
+export * from './actions/product-percentage-discount-action';
 export * from './conditions/has-facet-values-condition';
 export * from './conditions/min-order-amount-condition';
 export * from './conditions/contains-products-condition';
@@ -23,6 +24,7 @@ export const defaultPromotionActions = [
     orderPercentageDiscount,
     discountOnItemWithFacets,
     productsPercentageDiscount,
+    freeShipping,
 ];
 export const defaultPromotionConditions = [
     minimumOrderAmount,

+ 51 - 0
packages/core/src/config/promotion/promotion-action.ts

@@ -10,6 +10,7 @@ import {
 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';
 
 /**
  * @description
@@ -40,6 +41,21 @@ export type ExecutePromotionOrderActionFn<T extends ConfigArgs> = (
     args: ConfigArgValues<T>,
 ) => number | Promise<number>;
 
+/**
+ * @description
+ * The function which is used by a PromotionOrderAction to calculate the
+ * discount on the Order.
+ *
+ * @docsCategory promotions
+ * @docsPage promotion-action
+ */
+export type ExecutePromotionShippingActionFn<T extends ConfigArgs> = (
+    ctx: RequestContext,
+    shippingLine: ShippingLine,
+    order: Order,
+    args: ConfigArgValues<T>,
+) => number | Promise<number>;
+
 export interface PromotionActionConfig<T extends ConfigArgs> extends ConfigurableOperationDefOptions<T> {
     priorityValue?: number;
 }
@@ -73,6 +89,20 @@ export interface PromotionOrderActionConfig<T extends ConfigArgs> extends Promot
     execute: ExecutePromotionOrderActionFn<T>;
 }
 
+/**
+ * @description
+ *
+ * @docsCategory promotions
+ * @docsPage promotion-action
+ */
+export interface PromotionShippingActionConfig<T extends ConfigArgs> extends PromotionActionConfig<T> {
+    /**
+     * @description
+     * The function which contains the promotion calculation logic.
+     */
+    execute: ExecutePromotionShippingActionFn<T>;
+}
+
 /**
  * @description
  * An abstract class which is extended by {@link PromotionItemAction} and {@link PromotionOrderAction}.
@@ -165,3 +195,24 @@ export class PromotionOrderAction<T extends ConfigArgs = ConfigArgs> extends Pro
         return this.executeFn(ctx, order, this.argsArrayToHash(args));
     }
 }
+
+/**
+ * @description
+ * Represents a PromotionAction which applies to the shipping cost of an Order.
+ *
+ * @docsCategory promotions
+ * @docsPage promotion-action
+ * @docsWeight 3
+ */
+export class PromotionShippingAction<T extends ConfigArgs = ConfigArgs> extends PromotionAction<T> {
+    private readonly executeFn: ExecutePromotionShippingActionFn<T>;
+    constructor(config: PromotionShippingActionConfig<T>) {
+        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));
+    }
+}

+ 10 - 0
packages/core/src/entity/order/order.entity.ts

@@ -107,6 +107,16 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
                 }
             }
         }
+        for (const shippingLine of this.shippingLines) {
+            for (const discount of shippingLine.discounts) {
+                const adjustment = groupedAdjustments.get(discount.adjustmentSource);
+                if (adjustment) {
+                    adjustment.amount += discount.amount;
+                } else {
+                    groupedAdjustments.set(discount.adjustmentSource, { ...discount });
+                }
+            }
+        }
         return [...groupedAdjustments.values()];
     }
 

+ 37 - 8
packages/core/src/entity/promotion/promotion.entity.ts

@@ -10,12 +10,14 @@ import {
     PromotionAction,
     PromotionItemAction,
     PromotionOrderAction,
+    PromotionShippingAction,
 } from '../../config/promotion/promotion-action';
 import { PromotionCondition } 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';
 import { Order } from '../order/order.entity';
+import { ShippingLine } from '../shipping-line/shipping-line.entity';
 
 export interface ApplyOrderItemActionArgs {
     orderItem: OrderItem;
@@ -26,6 +28,11 @@ export interface ApplyOrderActionArgs {
     order: Order;
 }
 
+export interface ApplyShippingActionArgs {
+    shippingLine: ShippingLine;
+    order: Order;
+}
+
 /**
  * @description
  * A Promotion is used to define a set of conditions under which promotions actions (typically discounts)
@@ -40,7 +47,9 @@ export interface ApplyOrderActionArgs {
 export class Promotion extends AdjustmentSource implements ChannelAware, SoftDeletable {
     type = AdjustmentType.PROMOTION;
     private readonly allConditions: { [code: string]: PromotionCondition } = {};
-    private readonly allActions: { [code: string]: PromotionItemAction | PromotionOrderAction } = {};
+    private readonly allActions: {
+        [code: string]: PromotionItemAction | PromotionOrderAction | PromotionShippingAction;
+    } = {};
 
     constructor(
         input?: DeepPartial<Promotion> & {
@@ -103,24 +112,31 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
 
     async apply(
         ctx: RequestContext,
-        args: ApplyOrderActionArgs | ApplyOrderItemActionArgs,
+        args: ApplyOrderActionArgs | ApplyOrderItemActionArgs | ApplyShippingActionArgs,
     ): Promise<Adjustment | undefined> {
         let amount = 0;
 
         for (const action of this.actions) {
             const promotionAction = this.allActions[action.code];
-            if (this.isItemAction(promotionAction)) {
+            if (promotionAction instanceof PromotionItemAction) {
                 if (this.isOrderItemArg(args)) {
                     const { orderItem, orderLine } = args;
                     amount += Math.round(
                         await promotionAction.execute(ctx, orderItem, orderLine, action.args),
                     );
                 }
-            } else {
-                if (!this.isOrderItemArg(args)) {
+            } else if (promotionAction instanceof PromotionOrderAction) {
+                if (this.isOrderArg(args)) {
                     const { order } = args;
                     amount += Math.round(await promotionAction.execute(ctx, order, action.args));
                 }
+            } else if (promotionAction instanceof PromotionShippingAction) {
+                if (this.isShippingArg(args)) {
+                    const { shippingLine, order } = args;
+                    amount += Math.round(
+                        await promotionAction.execute(ctx, shippingLine, order, action.args),
+                    );
+                }
             }
         }
         if (amount !== 0) {
@@ -151,14 +167,27 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
         }
         return true;
     }
+    private isShippingAction(
+        value: PromotionItemAction | PromotionOrderAction | PromotionShippingAction,
+    ): value is PromotionItemAction {
+        return value instanceof PromotionShippingAction;
+    }
 
-    private isItemAction(value: PromotionItemAction | PromotionOrderAction): value is PromotionItemAction {
-        return value instanceof PromotionItemAction;
+    private isOrderArg(
+        value: ApplyOrderItemActionArgs | ApplyOrderActionArgs | ApplyShippingActionArgs,
+    ): value is ApplyOrderActionArgs {
+        return !this.isOrderItemArg(value) && !this.isShippingArg(value);
     }
 
     private isOrderItemArg(
-        value: ApplyOrderItemActionArgs | ApplyOrderActionArgs,
+        value: ApplyOrderItemActionArgs | ApplyOrderActionArgs | ApplyShippingActionArgs,
     ): value is ApplyOrderItemActionArgs {
         return value.hasOwnProperty('orderItem');
     }
+
+    private isShippingArg(
+        value: ApplyOrderItemActionArgs | ApplyOrderActionArgs | PromotionShippingAction,
+    ): value is ApplyShippingActionArgs {
+        return value.hasOwnProperty('shippingLine');
+    }
 }

+ 26 - 2
packages/core/src/entity/shipping-line/shipping-line.entity.ts

@@ -1,8 +1,10 @@
-import { Adjustment } from '@vendure/common/lib/generated-types';
+import { Adjustment, AdjustmentType } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
+import { summate } from '@vendure/common/lib/shared-utils';
 import { Column, Entity, ManyToOne } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
+import { grossPriceOf, netPriceOf } from '../../common/tax-utils';
 import { VendureEntity } from '../base/base.entity';
 import { EntityId } from '../entity-id.decorator';
 import { Order } from '../order/order.entity';
@@ -29,11 +31,33 @@ export class ShippingLine extends VendureEntity {
     @Column()
     priceWithTax: number;
 
+    @Calculated()
+    get discountedPrice(): number {
+        return this.price + this.getAdjustmentsTotal();
+    }
+
+    @Calculated()
+    get discountedPriceWithTax(): number {
+        return this.priceWithTax + this.getAdjustmentsTotal();
+    }
+
     @Column('simple-json')
     adjustments: Adjustment[];
 
     @Calculated()
     get discounts(): Adjustment[] {
-        return this.adjustments;
+        return this.adjustments || [];
+    }
+
+    addAdjustment(adjustment: Adjustment) {
+        this.adjustments = this.adjustments.concat(adjustment);
+    }
+
+    /**
+     * @description
+     * The total of all price adjustments. Will typically be a negative number due to discounts.
+     */
+    private getAdjustmentsTotal(): number {
+        return summate(this.adjustments, 'amount');
     }
 }

+ 149 - 18
packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -4,16 +4,17 @@ import { Omit } from '@vendure/common/lib/omit';
 import { summate } from '@vendure/common/lib/shared-utils';
 
 import { RequestContext } from '../../../api/common/request-context';
-import { PromotionItemAction, PromotionOrderAction } from '../../../config';
+import { PromotionItemAction, PromotionOrderAction, PromotionShippingAction } from '../../../config';
 import { ConfigService } from '../../../config/config.service';
 import { MockConfigService } from '../../../config/config.service.mock';
 import { PromotionCondition } from '../../../config/promotion/promotion-condition';
 import { DefaultTaxCalculationStrategy } from '../../../config/tax/default-tax-calculation-strategy';
 import { DefaultTaxZoneStrategy } from '../../../config/tax/default-tax-zone-strategy';
-import { Promotion } from '../../../entity';
+import { Promotion, ShippingMethod } 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 { TaxCategory } from '../../../entity/tax-category/tax-category.entity';
 import { EventBus } from '../../../event-bus/event-bus';
 import { WorkerService } from '../../../worker/worker.service';
@@ -36,6 +37,19 @@ import { OrderCalculator } from './order-calculator';
 describe('OrderCalculator', () => {
     let orderCalculator: OrderCalculator;
 
+    const mockShippingMethod = {
+        price: 500,
+        priceWithTax: 600,
+        id: 'T_1',
+        test: () => true,
+        apply() {
+            return {
+                price: this.price,
+                priceWithTax: this.priceWithTax,
+            };
+        },
+    };
+
     beforeEach(async () => {
         const module = await Test.createTestingModule({
             providers: [
@@ -43,7 +57,7 @@ describe('OrderCalculator', () => {
                 TaxCalculator,
                 TaxRateService,
                 { provide: ShippingCalculator, useValue: { getEligibleShippingMethods: () => [] } },
-                { provide: ShippingMethodService, useValue: { findOne: () => undefined } },
+                { provide: ShippingMethodService, useValue: { findOne: () => mockShippingMethod } },
                 { provide: TransactionalConnection, useClass: MockConnection },
                 { provide: ListQueryBuilder, useValue: {} },
                 { provide: ConfigService, useClass: MockConfigService },
@@ -113,6 +127,35 @@ describe('OrderCalculator', () => {
         });
     });
 
+    describe('shipping', () => {
+        it('prices exclude tax', async () => {
+            const ctx = createRequestContext({ pricesIncludeTax: false });
+            const order = createOrder({
+                ctx,
+                lines: [
+                    {
+                        listPrice: 100,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 1,
+                    },
+                ],
+            });
+            order.shippingLines = [
+                new ShippingLine({
+                    shippingMethodId: mockShippingMethod.id,
+                }),
+            ];
+            await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+            expect(order.subTotal).toBe(100);
+            expect(order.shipping).toBe(mockShippingMethod.price);
+            expect(order.shippingWithTax).toBe(mockShippingMethod.priceWithTax);
+            expect(order.total).toBe(order.subTotal + mockShippingMethod.price);
+            expect(order.totalWithTax).toBe(order.subTotalWithTax + mockShippingMethod.priceWithTax);
+            assertOrderTotalsAddUp(order);
+        });
+    });
+
     describe('promotions', () => {
         const alwaysTrueCondition = new PromotionCondition({
             args: {},
@@ -183,6 +226,15 @@ describe('OrderCalculator', () => {
             },
         });
 
+        const freeShippingAction = new PromotionShippingAction({
+            code: 'free_shipping',
+            description: [{ languageCode: LanguageCode.en, value: 'Free shipping' }],
+            args: {},
+            execute(ctx, shippingLine, order, args) {
+                return -shippingLine.price;
+            },
+        });
+
         it('empty string couponCode does not prevent promotion being applied', async () => {
             const hasEmptyStringCouponCode = new Promotion({
                 id: 2,
@@ -221,7 +273,7 @@ describe('OrderCalculator', () => {
             expect(order.discounts[0].description).toBe(hasEmptyStringCouponCode.name);
         });
 
-        describe('OrderItem-level promotions', () => {
+        describe('OrderItem-level discounts', () => {
             describe('percentage items discount', () => {
                 const promotion = new Promotion({
                     id: 1,
@@ -482,7 +534,7 @@ describe('OrderCalculator', () => {
                 });
             });
 
-            it('percentage order discount', async () => {
+            describe('percentage order discount', () => {
                 const promotion = new Promotion({
                     id: 1,
                     name: '50% off order',
@@ -497,24 +549,102 @@ describe('OrderCalculator', () => {
                     promotionActions: [percentageOrderAction],
                 });
 
-                const ctx = createRequestContext({ pricesIncludeTax: false });
-                const order = createOrder({
-                    ctx,
-                    lines: [
+                it('prices exclude tax', async () => {
+                    const ctx = createRequestContext({ pricesIncludeTax: false });
+                    const order = createOrder({
+                        ctx,
+                        lines: [
+                            {
+                                listPrice: 100,
+                                taxCategory: taxCategoryStandard,
+                                quantity: 1,
+                            },
+                        ],
+                    });
+                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+                    expect(order.subTotal).toBe(50);
+                    expect(order.discounts.length).toBe(1);
+                    expect(order.discounts[0].description).toBe('50% off order');
+                    expect(order.totalWithTax).toBe(60);
+                    assertOrderTotalsAddUp(order);
+                });
+
+                it('prices include tax', async () => {
+                    const ctx = createRequestContext({ pricesIncludeTax: true });
+                    const order = createOrder({
+                        ctx,
+                        lines: [
+                            {
+                                listPrice: 100,
+                                taxCategory: taxCategoryStandard,
+                                quantity: 1,
+                            },
+                        ],
+                    });
+                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+                    expect(order.subTotal).toBe(42);
+                    expect(order.discounts.length).toBe(1);
+                    expect(order.discounts[0].description).toBe('50% off order');
+                    expect(order.totalWithTax).toBe(50);
+                    assertOrderTotalsAddUp(order);
+                });
+            });
+        });
+
+        describe('Shipping-level discounts', () => {
+            describe('free_shipping', () => {
+                const couponCode = 'FREE_SHIPPING';
+                const promotion = new Promotion({
+                    id: 1,
+                    name: 'Free shipping',
+                    couponCode,
+                    conditions: [],
+                    promotionConditions: [],
+                    actions: [
                         {
-                            listPrice: 100,
-                            taxCategory: taxCategoryStandard,
-                            quantity: 1,
+                            code: freeShippingAction.code,
+                            args: [],
                         },
                     ],
+                    promotionActions: [freeShippingAction],
                 });
-                await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
 
-                expect(order.subTotal).toBe(50);
-                expect(order.discounts.length).toBe(1);
-                expect(order.discounts[0].description).toBe('50% off order');
-                expect(order.totalWithTax).toBe(60);
-                assertOrderTotalsAddUp(order);
+                it('prices exclude tax', async () => {
+                    const ctx = createRequestContext({ pricesIncludeTax: false });
+                    const order = createOrder({
+                        ctx,
+                        lines: [
+                            {
+                                listPrice: 100,
+                                taxCategory: taxCategoryStandard,
+                                quantity: 1,
+                            },
+                        ],
+                    });
+                    order.shippingLines = [
+                        new ShippingLine({
+                            shippingMethodId: mockShippingMethod.id,
+                            adjustments: [],
+                        }),
+                    ];
+                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+                    expect(order.subTotal).toBe(100);
+                    expect(order.discounts.length).toBe(0);
+                    expect(order.total).toBe(order.subTotal + mockShippingMethod.price);
+                    assertOrderTotalsAddUp(order);
+
+                    order.couponCodes = [couponCode];
+                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+                    expect(order.subTotal).toBe(100);
+                    expect(order.discounts.length).toBe(1);
+                    expect(order.discounts[0].description).toBe('Free shipping');
+                    expect(order.total).toBe(order.subTotal);
+                    assertOrderTotalsAddUp(order);
+                });
             });
         });
 
@@ -930,6 +1060,7 @@ describe('OrderCalculator', () => {
         return new Order({
             couponCodes: [],
             lines,
+            shippingLines: [],
         });
     }
 

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

@@ -81,6 +81,7 @@ export class OrderCalculator {
                 this.applyTaxes(ctx, order, activeTaxZone);
             }
             await this.applyShipping(ctx, order);
+            await this.applyShippingPromotions(ctx, order, promotions);
         }
         this.calculateOrderTotals(order);
         return taxZoneChanged ? order.getOrderItems() : Array.from(updatedOrderItems);
@@ -302,6 +303,25 @@ export class OrderCalculator {
         return Array.from(updatedItems.values());
     }
 
+    private async applyShippingPromotions(ctx: RequestContext, order: Order, promotions: Promotion[]) {
+        const applicableOrderPromotions = await filterAsync(promotions, p => p.test(ctx, order));
+        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)) {
+                    for (const shippingLine of order.shippingLines) {
+                        const adjustment = await promotion.apply(ctx, { shippingLine, order });
+                        if (adjustment && adjustment.amount !== 0) {
+                            shippingLine.addAdjustment(adjustment);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
     private async applyShipping(ctx: RequestContext, order: Order) {
         const shippingLine: ShippingLine | undefined = order.shippingLines[0];
         const currentShippingMethod =
@@ -345,8 +365,8 @@ export class OrderCalculator {
         let shippingPrice = 0;
         let shippingPriceWithTax = 0;
         for (const shippingLine of order.shippingLines) {
-            shippingPrice += shippingLine.price;
-            shippingPriceWithTax += shippingLine.priceWithTax;
+            shippingPrice += shippingLine.discountedPrice;
+            shippingPriceWithTax += shippingLine.discountedPriceWithTax;
         }
 
         order.shipping = shippingPrice;

+ 14 - 12
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -1111,6 +1111,18 @@ export type UpdateFacetValueInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type Fulfillment = Node & {
+    nextStates: Array<Scalars['String']>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    orderItems: Array<OrderItem>;
+    state: Scalars['String'];
+    method: Scalars['String'];
+    trackingCode?: Maybe<Scalars['String']>;
+    customFields?: Maybe<Scalars['JSON']>;
+};
+
 export type UpdateGlobalSettingsInput = {
     availableLanguages?: Maybe<Array<LanguageCode>>;
     trackInventory?: Maybe<Scalars['Boolean']>;
@@ -1274,18 +1286,6 @@ export type OrderHistoryArgs = {
     options?: Maybe<HistoryEntryListOptions>;
 };
 
-export type Fulfillment = Node & {
-    nextStates: Array<Scalars['String']>;
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    orderItems: Array<OrderItem>;
-    state: Scalars['String'];
-    method: Scalars['String'];
-    trackingCode?: Maybe<Scalars['String']>;
-    customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type UpdateOrderInput = {
     id: Scalars['ID'];
     customFields?: Maybe<Scalars['JSON']>;
@@ -3329,6 +3329,8 @@ export type ShippingLine = {
     shippingMethod: ShippingMethod;
     price: Scalars['Int'];
     priceWithTax: Scalars['Int'];
+    discountedPrice: Scalars['Int'];
+    discountedPriceWithTax: Scalars['Int'];
     discounts: Array<Adjustment>;
 };
 

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
schema-admin.json


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
schema-shop.json


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff