Michael Bromley пре 6 година
родитељ
комит
95bf868e19
36 измењених фајлова са 1487 додато и 219 уклоњено
  1. 16 0
      packages/admin-ui/src/app/common/generated-types.ts
  2. 22 0
      packages/common/src/generated-shop-types.ts
  3. 26 0
      packages/common/src/generated-types.ts
  4. 6 0
      packages/core/e2e/__snapshots__/promotion.e2e-spec.ts.snap
  5. 6 0
      packages/core/e2e/fixtures/e2e-products-promotions.csv
  6. 3 0
      packages/core/e2e/graphql/fragments.ts
  7. 89 15
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  8. 89 2
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  9. 9 0
      packages/core/e2e/graphql/shared-definitions.ts
  10. 46 0
      packages/core/e2e/graphql/shop-definitions.ts
  11. 621 0
      packages/core/e2e/order-promotion.e2e-spec.ts
  12. 3 64
      packages/core/e2e/order.e2e-spec.ts
  13. 54 12
      packages/core/e2e/promotion.e2e-spec.ts
  14. 7 23
      packages/core/e2e/shop-order.e2e-spec.ts
  15. 82 0
      packages/core/e2e/utils/test-order-utils.ts
  16. 13 3
      packages/core/src/api/resolvers/entity/order-entity.resolver.ts
  17. 21 0
      packages/core/src/api/resolvers/shop/shop-order.resolver.ts
  18. 8 0
      packages/core/src/api/schema/admin-api/promotion.api.graphql
  19. 4 0
      packages/core/src/api/schema/shop-api/shop.api.graphql
  20. 6 0
      packages/core/src/api/schema/type/order.type.graphql
  21. 4 0
      packages/core/src/api/schema/type/promotion.type.graphql
  22. 39 0
      packages/core/src/common/error/errors.ts
  23. 9 0
      packages/core/src/common/types/adjustment-source.ts
  24. 1 37
      packages/core/src/config/promotion/default-promotion-actions.ts
  25. 1 33
      packages/core/src/config/promotion/default-promotion-conditions.ts
  26. 4 1
      packages/core/src/entity/order-item/order-item.entity.ts
  27. 14 1
      packages/core/src/entity/order/order.entity.ts
  28. 21 0
      packages/core/src/entity/promotion/promotion.entity.ts
  29. 4 0
      packages/core/src/i18n/messages/en.json
  30. 125 1
      packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts
  31. 3 0
      packages/core/src/service/helpers/order-state-machine/order-state-machine.ts
  32. 45 2
      packages/core/src/service/services/order.service.ts
  33. 85 14
      packages/core/src/service/services/promotion.service.ts
  34. 1 11
      packages/dev-server/dev-config.ts
  35. 0 0
      schema-admin.json
  36. 0 0
      schema-shop.json

+ 16 - 0
packages/admin-ui/src/app/common/generated-types.ts

@@ -524,6 +524,9 @@ export type CreateProductVariantOptionInput = {
 export type CreatePromotionInput = {
   name: Scalars['String'],
   enabled: Scalars['Boolean'],
+  startsAt?: Maybe<Scalars['DateTime']>,
+  endsAt?: Maybe<Scalars['DateTime']>,
+  couponCode?: Maybe<Scalars['String']>,
   conditions: Array<ConfigurableOperationInput>,
   actions: Array<ConfigurableOperationInput>,
 };
@@ -2188,6 +2191,7 @@ export type Order = Node & {
   billingAddress?: Maybe<OrderAddress>,
   lines: Array<OrderLine>,
   adjustments: Array<Adjustment>,
+  couponCodes: Array<Scalars['String']>,
   payments?: Maybe<Array<Payment>>,
   fulfillments?: Maybe<Array<Fulfillment>>,
   subTotalBeforeTax: Scalars['Int'],
@@ -2629,6 +2633,9 @@ export type Promotion = Node & {
   id: Scalars['ID'],
   createdAt: Scalars['DateTime'],
   updatedAt: Scalars['DateTime'],
+  startsAt?: Maybe<Scalars['DateTime']>,
+  endsAt?: Maybe<Scalars['DateTime']>,
+  couponCode?: Maybe<Scalars['String']>,
   name: Scalars['String'],
   enabled: Scalars['Boolean'],
   conditions: Array<ConfigurableOperation>,
@@ -2638,6 +2645,9 @@ export type Promotion = Node & {
 export type PromotionFilterParameter = {
   createdAt?: Maybe<DateOperators>,
   updatedAt?: Maybe<DateOperators>,
+  startsAt?: Maybe<DateOperators>,
+  endsAt?: Maybe<DateOperators>,
+  couponCode?: Maybe<StringOperators>,
   name?: Maybe<StringOperators>,
   enabled?: Maybe<BooleanOperators>,
 };
@@ -2659,6 +2669,9 @@ export type PromotionSortParameter = {
   id?: Maybe<SortOrder>,
   createdAt?: Maybe<SortOrder>,
   updatedAt?: Maybe<SortOrder>,
+  startsAt?: Maybe<SortOrder>,
+  endsAt?: Maybe<SortOrder>,
+  couponCode?: Maybe<SortOrder>,
   name?: Maybe<SortOrder>,
 };
 
@@ -3403,6 +3416,9 @@ export type UpdatePromotionInput = {
   id: Scalars['ID'],
   name?: Maybe<Scalars['String']>,
   enabled?: Maybe<Scalars['Boolean']>,
+  startsAt?: Maybe<Scalars['DateTime']>,
+  endsAt?: Maybe<Scalars['DateTime']>,
+  couponCode?: Maybe<Scalars['String']>,
   conditions?: Maybe<Array<ConfigurableOperationInput>>,
   actions?: Maybe<Array<ConfigurableOperationInput>>,
 };

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

@@ -1301,6 +1301,10 @@ export type Mutation = {
      * third argument 'customFields' will be available.
      */
     adjustOrderLine?: Maybe<Order>;
+    /** Applies the given coupon code to the active Order */
+    applyCouponCode?: Maybe<Order>;
+    /** Removes the given coupon code from the active Order */
+    removeCouponCode?: Maybe<Order>;
     transitionOrderToState?: Maybe<Order>;
     setOrderShippingAddress?: Maybe<Order>;
     setOrderShippingMethod?: Maybe<Order>;
@@ -1358,6 +1362,14 @@ export type MutationAdjustOrderLineArgs = {
     quantity?: Maybe<Scalars['Int']>;
 };
 
+export type MutationApplyCouponCodeArgs = {
+    couponCode: Scalars['String'];
+};
+
+export type MutationRemoveCouponCodeArgs = {
+    couponCode: Scalars['String'];
+};
+
 export type MutationTransitionOrderToStateArgs = {
     state: Scalars['String'];
 };
@@ -1460,17 +1472,23 @@ export type Order = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
+    /** A unique code for the Order */
     code: Scalars['String'];
     state: Scalars['String'];
+    /** An order is active as long as the payment process has not been completed */
     active: Scalars['Boolean'];
     customer?: Maybe<Customer>;
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
+    /** Order-level adjustments to the order total, such as discounts from promotions */
     adjustments: Array<Adjustment>;
+    couponCodes: Array<Scalars['String']>;
+    promotions: Array<Promotion>;
     payments?: Maybe<Array<Payment>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     subTotalBeforeTax: Scalars['Int'];
+    /** The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied. */
     subTotal: Scalars['Int'];
     currencyCode: CurrencyCode;
     shipping: Scalars['Int'];
@@ -1842,6 +1860,10 @@ export type Promotion = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
+    startsAt?: Maybe<Scalars['DateTime']>;
+    endsAt?: Maybe<Scalars['DateTime']>;
+    couponCode?: Maybe<Scalars['String']>;
+    perCustomerUsageLimit?: Maybe<Scalars['Int']>;
     name: Scalars['String'];
     enabled: Scalars['Boolean'];
     conditions: Array<ConfigurableOperation>;

+ 26 - 0
packages/common/src/generated-types.ts

@@ -523,6 +523,10 @@ export type CreateProductVariantOptionInput = {
 export type CreatePromotionInput = {
   name: Scalars['String'],
   enabled: Scalars['Boolean'],
+  startsAt?: Maybe<Scalars['DateTime']>,
+  endsAt?: Maybe<Scalars['DateTime']>,
+  couponCode?: Maybe<Scalars['String']>,
+  perCustomerUsageLimit?: Maybe<Scalars['Int']>,
   conditions: Array<ConfigurableOperationInput>,
   actions: Array<ConfigurableOperationInput>,
 };
@@ -2157,17 +2161,23 @@ export type Order = Node & {
   id: Scalars['ID'],
   createdAt: Scalars['DateTime'],
   updatedAt: Scalars['DateTime'],
+  /** A unique code for the Order */
   code: Scalars['String'],
   state: Scalars['String'],
+  /** An order is active as long as the payment process has not been completed */
   active: Scalars['Boolean'],
   customer?: Maybe<Customer>,
   shippingAddress?: Maybe<OrderAddress>,
   billingAddress?: Maybe<OrderAddress>,
   lines: Array<OrderLine>,
+  /** Order-level adjustments to the order total, such as discounts from promotions */
   adjustments: Array<Adjustment>,
+  couponCodes: Array<Scalars['String']>,
+  promotions: Array<Promotion>,
   payments?: Maybe<Array<Payment>>,
   fulfillments?: Maybe<Array<Fulfillment>>,
   subTotalBeforeTax: Scalars['Int'],
+  /** The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied. */
   subTotal: Scalars['Int'],
   currencyCode: CurrencyCode,
   shipping: Scalars['Int'],
@@ -2606,6 +2616,10 @@ export type Promotion = Node & {
   id: Scalars['ID'],
   createdAt: Scalars['DateTime'],
   updatedAt: Scalars['DateTime'],
+  startsAt?: Maybe<Scalars['DateTime']>,
+  endsAt?: Maybe<Scalars['DateTime']>,
+  couponCode?: Maybe<Scalars['String']>,
+  perCustomerUsageLimit?: Maybe<Scalars['Int']>,
   name: Scalars['String'],
   enabled: Scalars['Boolean'],
   conditions: Array<ConfigurableOperation>,
@@ -2615,6 +2629,10 @@ export type Promotion = Node & {
 export type PromotionFilterParameter = {
   createdAt?: Maybe<DateOperators>,
   updatedAt?: Maybe<DateOperators>,
+  startsAt?: Maybe<DateOperators>,
+  endsAt?: Maybe<DateOperators>,
+  couponCode?: Maybe<StringOperators>,
+  perCustomerUsageLimit?: Maybe<NumberOperators>,
   name?: Maybe<StringOperators>,
   enabled?: Maybe<BooleanOperators>,
 };
@@ -2636,6 +2654,10 @@ export type PromotionSortParameter = {
   id?: Maybe<SortOrder>,
   createdAt?: Maybe<SortOrder>,
   updatedAt?: Maybe<SortOrder>,
+  startsAt?: Maybe<SortOrder>,
+  endsAt?: Maybe<SortOrder>,
+  couponCode?: Maybe<SortOrder>,
+  perCustomerUsageLimit?: Maybe<SortOrder>,
   name?: Maybe<SortOrder>,
 };
 
@@ -3372,6 +3394,10 @@ export type UpdatePromotionInput = {
   id: Scalars['ID'],
   name?: Maybe<Scalars['String']>,
   enabled?: Maybe<Scalars['Boolean']>,
+  startsAt?: Maybe<Scalars['DateTime']>,
+  endsAt?: Maybe<Scalars['DateTime']>,
+  couponCode?: Maybe<Scalars['String']>,
+  perCustomerUsageLimit?: Maybe<Scalars['Int']>,
   conditions?: Maybe<Array<ConfigurableOperationInput>>,
   actions?: Maybe<Array<ConfigurableOperationInput>>,
 };

+ 6 - 0
packages/core/e2e/__snapshots__/promotion.e2e-spec.ts.snap

@@ -69,8 +69,11 @@ Object {
       "code": "promo_condition",
     },
   ],
+  "couponCode": "TEST123",
   "enabled": true,
+  "endsAt": "2019-11-30T23:00:00.000Z",
   "name": "test promotion",
+  "startsAt": "2019-10-29T23:00:00.000Z",
 }
 `;
 
@@ -110,7 +113,10 @@ Object {
       "code": "promo_condition2",
     },
   ],
+  "couponCode": "TEST1235",
   "enabled": true,
+  "endsAt": "2019-05-31T22:00:00.000Z",
   "name": "test promotion",
+  "startsAt": "2019-05-29T22:00:00.000Z",
 }
 `;

+ 6 - 0
packages/core/e2e/fixtures/e2e-products-promotions.csv

@@ -0,0 +1,6 @@
+name         , slug         , description , assets , facets     , optionGroups , optionValues , sku  , price , taxCategory , stockOnHand , trackInventory , variantAssets , variantFacets
+item-1       , item-1       ,             ,        ,            ,              ,              , I1   , 1.00  , standard    , 100         , false          ,               ,
+item-12      , item-12      ,             ,        ,            ,              ,              , I12  , 10.00 , standard    , 100         , false          ,               ,
+item-60      , item-60      ,             ,        ,            ,              ,              , I60  , 50.00 , standard    , 100         , false          ,               ,
+item-sale-1  , item-sale-1  ,             ,        , promo:sale ,              ,              , I10  , 1.00  , standard    , 100         , false          ,               ,
+item-sale-12 , item-sale-12 ,             ,        , promo:sale ,              ,              , I12S , 10.00 , standard    , 100         , false          ,               ,

+ 3 - 0
packages/core/e2e/graphql/fragments.ts

@@ -401,6 +401,9 @@ export const PROMOTION_FRAGMENT = gql`
         id
         createdAt
         updatedAt
+        couponCode
+        startsAt
+        endsAt
         name
         enabled
         conditions {

+ 89 - 15
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -523,6 +523,10 @@ export type CreateProductVariantOptionInput = {
 export type CreatePromotionInput = {
     name: Scalars['String'];
     enabled: Scalars['Boolean'];
+    startsAt?: Maybe<Scalars['DateTime']>;
+    endsAt?: Maybe<Scalars['DateTime']>;
+    couponCode?: Maybe<Scalars['String']>;
+    perCustomerUsageLimit?: Maybe<Scalars['Int']>;
     conditions: Array<ConfigurableOperationInput>;
     actions: Array<ConfigurableOperationInput>;
 };
@@ -2092,17 +2096,23 @@ export type Order = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
+    /** A unique code for the Order */
     code: Scalars['String'];
     state: Scalars['String'];
+    /** An order is active as long as the payment process has not been completed */
     active: Scalars['Boolean'];
     customer?: Maybe<Customer>;
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
+    /** Order-level adjustments to the order total, such as discounts from promotions */
     adjustments: Array<Adjustment>;
+    couponCodes: Array<Scalars['String']>;
+    promotions: Array<Promotion>;
     payments?: Maybe<Array<Payment>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     subTotalBeforeTax: Scalars['Int'];
+    /** The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied. */
     subTotal: Scalars['Int'];
     currencyCode: CurrencyCode;
     shipping: Scalars['Int'];
@@ -2539,6 +2549,10 @@ export type Promotion = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
+    startsAt?: Maybe<Scalars['DateTime']>;
+    endsAt?: Maybe<Scalars['DateTime']>;
+    couponCode?: Maybe<Scalars['String']>;
+    perCustomerUsageLimit?: Maybe<Scalars['Int']>;
     name: Scalars['String'];
     enabled: Scalars['Boolean'];
     conditions: Array<ConfigurableOperation>;
@@ -2548,6 +2562,10 @@ export type Promotion = Node & {
 export type PromotionFilterParameter = {
     createdAt?: Maybe<DateOperators>;
     updatedAt?: Maybe<DateOperators>;
+    startsAt?: Maybe<DateOperators>;
+    endsAt?: Maybe<DateOperators>;
+    couponCode?: Maybe<StringOperators>;
+    perCustomerUsageLimit?: Maybe<NumberOperators>;
     name?: Maybe<StringOperators>;
     enabled?: Maybe<BooleanOperators>;
 };
@@ -2569,6 +2587,10 @@ export type PromotionSortParameter = {
     id?: Maybe<SortOrder>;
     createdAt?: Maybe<SortOrder>;
     updatedAt?: Maybe<SortOrder>;
+    startsAt?: Maybe<SortOrder>;
+    endsAt?: Maybe<SortOrder>;
+    couponCode?: Maybe<SortOrder>;
+    perCustomerUsageLimit?: Maybe<SortOrder>;
     name?: Maybe<SortOrder>;
 };
 
@@ -3271,6 +3293,10 @@ export type UpdatePromotionInput = {
     id: Scalars['ID'];
     name?: Maybe<Scalars['String']>;
     enabled?: Maybe<Scalars['Boolean']>;
+    startsAt?: Maybe<Scalars['DateTime']>;
+    endsAt?: Maybe<Scalars['DateTime']>;
+    couponCode?: Maybe<Scalars['String']>;
+    perCustomerUsageLimit?: Maybe<Scalars['Int']>;
     conditions?: Maybe<Array<ConfigurableOperationInput>>;
     actions?: Maybe<Array<ConfigurableOperationInput>>;
 };
@@ -4055,7 +4081,7 @@ export type OrderWithLinesFragment = { __typename?: 'Order' } & Pick<
 
 export type PromotionFragment = { __typename?: 'Promotion' } & Pick<
     Promotion,
-    'id' | 'createdAt' | 'updatedAt' | 'name' | 'enabled'
+    'id' | 'createdAt' | 'updatedAt' | 'couponCode' | 'startsAt' | 'endsAt' | 'name' | 'enabled'
 > & {
         conditions: Array<{ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment>;
         actions: Array<{ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment>;
@@ -4323,6 +4349,14 @@ export type GetRunningJobsQuery = { __typename?: 'Query' } & {
     jobs: Array<{ __typename?: 'JobInfo' } & Pick<JobInfo, 'name' | 'state'>>;
 };
 
+export type CreatePromotionMutationVariables = {
+    input: CreatePromotionInput;
+};
+
+export type CreatePromotionMutation = { __typename?: 'Mutation' } & {
+    createPromotion: { __typename?: 'Promotion' } & PromotionFragment;
+};
+
 export type UpdateOptionGroupMutationVariables = {
     input: UpdateProductOptionGroupInput;
 };
@@ -4331,6 +4365,33 @@ export type UpdateOptionGroupMutation = { __typename?: 'Mutation' } & {
     updateProductOptionGroup: { __typename?: 'ProductOptionGroup' } & Pick<ProductOptionGroup, 'id'>;
 };
 
+export type DeletePromotionAdHoc1MutationVariables = {};
+
+export type DeletePromotionAdHoc1Mutation = { __typename?: 'Mutation' } & {
+    deletePromotion: { __typename?: 'DeletionResponse' } & Pick<DeletionResponse, 'result'>;
+};
+
+export type GetPromoProductsQueryVariables = {};
+
+export type GetPromoProductsQuery = { __typename?: 'Query' } & {
+    products: { __typename?: 'ProductList' } & {
+        items: Array<
+            { __typename?: 'Product' } & Pick<Product, 'id' | 'slug'> & {
+                    variants: Array<
+                        { __typename?: 'ProductVariant' } & Pick<
+                            ProductVariant,
+                            'id' | 'price' | 'priceWithTax' | 'sku'
+                        > & {
+                                facetValues: Array<
+                                    { __typename?: 'FacetValue' } & Pick<FacetValue, 'id' | 'code'>
+                                >;
+                            }
+                    >;
+                }
+        >;
+    };
+};
+
 export type GetOrderListQueryVariables = {
     options?: Maybe<OrderListOptions>;
 };
@@ -4612,14 +4673,6 @@ export type GetPromotionQuery = { __typename?: 'Query' } & {
     promotion: Maybe<{ __typename?: 'Promotion' } & PromotionFragment>;
 };
 
-export type CreatePromotionMutationVariables = {
-    input: CreatePromotionInput;
-};
-
-export type CreatePromotionMutation = { __typename?: 'Mutation' } & {
-    createPromotion: { __typename?: 'Promotion' } & PromotionFragment;
-};
-
 export type UpdatePromotionMutationVariables = {
     input: UpdatePromotionInput;
 };
@@ -5621,12 +5674,39 @@ export namespace GetRunningJobs {
     export type Jobs = NonNullable<GetRunningJobsQuery['jobs'][0]>;
 }
 
+export namespace CreatePromotion {
+    export type Variables = CreatePromotionMutationVariables;
+    export type Mutation = CreatePromotionMutation;
+    export type CreatePromotion = PromotionFragment;
+}
+
 export namespace UpdateOptionGroup {
     export type Variables = UpdateOptionGroupMutationVariables;
     export type Mutation = UpdateOptionGroupMutation;
     export type UpdateProductOptionGroup = UpdateOptionGroupMutation['updateProductOptionGroup'];
 }
 
+export namespace DeletePromotionAdHoc1 {
+    export type Variables = DeletePromotionAdHoc1MutationVariables;
+    export type Mutation = DeletePromotionAdHoc1Mutation;
+    export type DeletePromotion = DeletePromotionAdHoc1Mutation['deletePromotion'];
+}
+
+export namespace GetPromoProducts {
+    export type Variables = GetPromoProductsQueryVariables;
+    export type Query = GetPromoProductsQuery;
+    export type Products = GetPromoProductsQuery['products'];
+    export type Items = NonNullable<GetPromoProductsQuery['products']['items'][0]>;
+    export type Variants = NonNullable<
+        (NonNullable<GetPromoProductsQuery['products']['items'][0]>)['variants'][0]
+    >;
+    export type FacetValues = NonNullable<
+        (NonNullable<
+            (NonNullable<GetPromoProductsQuery['products']['items'][0]>)['variants'][0]
+        >)['facetValues'][0]
+    >;
+}
+
 export namespace GetOrderList {
     export type Variables = GetOrderListQueryVariables;
     export type Query = GetOrderListQuery;
@@ -5818,12 +5898,6 @@ export namespace GetPromotion {
     export type Promotion = PromotionFragment;
 }
 
-export namespace CreatePromotion {
-    export type Variables = CreatePromotionMutationVariables;
-    export type Mutation = CreatePromotionMutation;
-    export type CreatePromotion = PromotionFragment;
-}
-
 export namespace UpdatePromotion {
     export type Variables = UpdatePromotionMutationVariables;
     export type Mutation = UpdatePromotionMutation;

+ 89 - 2
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1301,6 +1301,10 @@ export type Mutation = {
      * third argument 'customFields' will be available.
      */
     adjustOrderLine?: Maybe<Order>;
+    /** Applies the given coupon code to the active Order */
+    applyCouponCode?: Maybe<Order>;
+    /** Removes the given coupon code from the active Order */
+    removeCouponCode?: Maybe<Order>;
     transitionOrderToState?: Maybe<Order>;
     setOrderShippingAddress?: Maybe<Order>;
     setOrderShippingMethod?: Maybe<Order>;
@@ -1358,6 +1362,14 @@ export type MutationAdjustOrderLineArgs = {
     quantity?: Maybe<Scalars['Int']>;
 };
 
+export type MutationApplyCouponCodeArgs = {
+    couponCode: Scalars['String'];
+};
+
+export type MutationRemoveCouponCodeArgs = {
+    couponCode: Scalars['String'];
+};
+
 export type MutationTransitionOrderToStateArgs = {
     state: Scalars['String'];
 };
@@ -1460,17 +1472,23 @@ export type Order = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
+    /** A unique code for the Order */
     code: Scalars['String'];
     state: Scalars['String'];
+    /** An order is active as long as the payment process has not been completed */
     active: Scalars['Boolean'];
     customer?: Maybe<Customer>;
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
+    /** Order-level adjustments to the order total, such as discounts from promotions */
     adjustments: Array<Adjustment>;
+    couponCodes: Array<Scalars['String']>;
+    promotions: Array<Promotion>;
     payments?: Maybe<Array<Payment>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     subTotalBeforeTax: Scalars['Int'];
+    /** The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied. */
     subTotal: Scalars['Int'];
     currencyCode: CurrencyCode;
     shipping: Scalars['Int'];
@@ -1842,6 +1860,10 @@ export type Promotion = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
+    startsAt?: Maybe<Scalars['DateTime']>;
+    endsAt?: Maybe<Scalars['DateTime']>;
+    couponCode?: Maybe<Scalars['String']>;
+    perCustomerUsageLimit?: Maybe<Scalars['Int']>;
     name: Scalars['String'];
     enabled: Scalars['Boolean'];
     conditions: Array<ConfigurableOperation>;
@@ -2194,8 +2216,14 @@ export type Zone = Node & {
 };
 export type TestOrderFragmentFragment = { __typename?: 'Order' } & Pick<
     Order,
-    'id' | 'code' | 'state' | 'active' | 'shipping'
+    'id' | 'code' | 'state' | 'active' | 'total' | 'couponCodes' | 'shipping'
 > & {
+        adjustments: Array<
+            { __typename?: 'Adjustment' } & Pick<
+                Adjustment,
+                'adjustmentSource' | 'amount' | 'description' | 'type'
+            >
+        >;
         lines: Array<
             { __typename?: 'OrderLine' } & Pick<OrderLine, 'id' | 'quantity'> & {
                     productVariant: { __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id'>;
@@ -2214,12 +2242,18 @@ export type AddItemToOrderMutationVariables = {
 
 export type AddItemToOrderMutation = { __typename?: 'Mutation' } & {
     addItemToOrder: Maybe<
-        { __typename?: 'Order' } & Pick<Order, 'id' | 'code' | 'state' | 'active'> & {
+        { __typename?: 'Order' } & Pick<Order, 'id' | 'code' | 'state' | 'active' | 'total'> & {
                 lines: Array<
                     { __typename?: 'OrderLine' } & Pick<OrderLine, 'id' | 'quantity'> & {
                             productVariant: { __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id'>;
                         }
                 >;
+                adjustments: Array<
+                    { __typename?: 'Adjustment' } & Pick<
+                        Adjustment,
+                        'adjustmentSource' | 'amount' | 'description' | 'type'
+                    >
+                >;
             }
     >;
 };
@@ -2428,6 +2462,18 @@ export type GetOrderByCodeQuery = { __typename?: 'Query' } & {
     orderByCode: Maybe<{ __typename?: 'Order' } & TestOrderFragmentFragment>;
 };
 
+export type GetOrderPromotionsByCodeQueryVariables = {
+    code: Scalars['String'];
+};
+
+export type GetOrderPromotionsByCodeQuery = { __typename?: 'Query' } & {
+    orderByCode: Maybe<
+        { __typename?: 'Order' } & {
+            promotions: Array<{ __typename?: 'Promotion' } & Pick<Promotion, 'id' | 'name'>>;
+        } & TestOrderFragmentFragment
+    >;
+};
+
 export type GetAvailableCountriesQueryVariables = {};
 
 export type GetAvailableCountriesQuery = { __typename?: 'Query' } & {
@@ -2526,8 +2572,25 @@ export type GetCustomerAddressesQuery = { __typename?: 'Query' } & {
         }
     >;
 };
+
+export type ApplyCouponCodeMutationVariables = {
+    couponCode: Scalars['String'];
+};
+
+export type ApplyCouponCodeMutation = { __typename?: 'Mutation' } & {
+    applyCouponCode: Maybe<{ __typename?: 'Order' } & TestOrderFragmentFragment>;
+};
+
+export type RemoveCouponCodeMutationVariables = {
+    couponCode: Scalars['String'];
+};
+
+export type RemoveCouponCodeMutation = { __typename?: 'Mutation' } & {
+    removeCouponCode: Maybe<{ __typename?: 'Order' } & TestOrderFragmentFragment>;
+};
 export namespace TestOrderFragment {
     export type Fragment = TestOrderFragmentFragment;
+    export type Adjustments = NonNullable<TestOrderFragmentFragment['adjustments'][0]>;
     export type Lines = NonNullable<TestOrderFragmentFragment['lines'][0]>;
     export type ProductVariant = (NonNullable<TestOrderFragmentFragment['lines'][0]>)['productVariant'];
     export type ShippingMethod = NonNullable<TestOrderFragmentFragment['shippingMethod']>;
@@ -2542,6 +2605,9 @@ export namespace AddItemToOrder {
     export type ProductVariant = (NonNullable<
         (NonNullable<AddItemToOrderMutation['addItemToOrder']>)['lines'][0]
     >)['productVariant'];
+    export type Adjustments = NonNullable<
+        (NonNullable<AddItemToOrderMutation['addItemToOrder']>)['adjustments'][0]
+    >;
 }
 
 export namespace SearchProductsShop {
@@ -2674,6 +2740,15 @@ export namespace GetOrderByCode {
     export type OrderByCode = TestOrderFragmentFragment;
 }
 
+export namespace GetOrderPromotionsByCode {
+    export type Variables = GetOrderPromotionsByCodeQueryVariables;
+    export type Query = GetOrderPromotionsByCodeQuery;
+    export type OrderByCode = TestOrderFragmentFragment;
+    export type Promotions = NonNullable<
+        (NonNullable<GetOrderPromotionsByCodeQuery['orderByCode']>)['promotions'][0]
+    >;
+}
+
 export namespace GetAvailableCountries {
     export type Variables = GetAvailableCountriesQueryVariables;
     export type Query = GetAvailableCountriesQuery;
@@ -2729,3 +2804,15 @@ export namespace GetCustomerAddresses {
         >)[0]
     >;
 }
+
+export namespace ApplyCouponCode {
+    export type Variables = ApplyCouponCodeMutationVariables;
+    export type Mutation = ApplyCouponCodeMutation;
+    export type ApplyCouponCode = TestOrderFragmentFragment;
+}
+
+export namespace RemoveCouponCode {
+    export type Variables = RemoveCouponCodeMutationVariables;
+    export type Mutation = RemoveCouponCodeMutation;
+    export type RemoveCouponCode = TestOrderFragmentFragment;
+}

+ 9 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -10,6 +10,7 @@ import {
     FACET_WITH_VALUES_FRAGMENT,
     PRODUCT_VARIANT_FRAGMENT,
     PRODUCT_WITH_VARIANTS_FRAGMENT,
+    PROMOTION_FRAGMENT,
     ROLE_FRAGMENT,
     TAX_RATE_FRAGMENT,
     VARIANT_WITH_STOCK_FRAGMENT,
@@ -274,3 +275,11 @@ export const GET_RUNNING_JOBS = gql`
         }
     }
 `;
+export const CREATE_PROMOTION = gql`
+    mutation CreatePromotion($input: CreatePromotionInput!) {
+        createPromotion(input: $input) {
+            ...Promotion
+        }
+    }
+    ${PROMOTION_FRAGMENT}
+`;

+ 46 - 0
packages/core/e2e/graphql/shop-definitions.ts

@@ -6,6 +6,14 @@ export const TEST_ORDER_FRAGMENT = gql`
         code
         state
         active
+        total
+        couponCodes
+        adjustments {
+            adjustmentSource
+            amount
+            description
+            type
+        }
         lines {
             id
             quantity
@@ -32,6 +40,7 @@ export const ADD_ITEM_TO_ORDER = gql`
             code
             state
             active
+            total
             lines {
                 id
                 quantity
@@ -39,6 +48,12 @@ export const ADD_ITEM_TO_ORDER = gql`
                     id
                 }
             }
+            adjustments {
+                adjustmentSource
+                amount
+                description
+                type
+            }
         }
     }
 `;
@@ -228,6 +243,19 @@ export const GET_ORDER_BY_CODE = gql`
     ${TEST_ORDER_FRAGMENT}
 `;
 
+export const GET_ORDER_PROMOTIONS_BY_CODE = gql`
+    query GetOrderPromotionsByCode($code: String!) {
+        orderByCode(code: $code) {
+            ...TestOrderFragment
+            promotions {
+                id
+                name
+            }
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;
+
 export const GET_AVAILABLE_COUNTRIES = gql`
     query GetAvailableCountries {
         availableCountries {
@@ -315,3 +343,21 @@ export const GET_ACTIVE_ORDER_ADDRESSES = gql`
         }
     }
 `;
+
+export const APPLY_COUPON_CODE = gql`
+    mutation ApplyCouponCode($couponCode: String!) {
+        applyCouponCode(couponCode: $couponCode) {
+            ...TestOrderFragment
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;
+
+export const REMOVE_COUPON_CODE = gql`
+    mutation RemoveCouponCode($couponCode: String!) {
+        removeCouponCode(couponCode: $couponCode) {
+            ...TestOrderFragment
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;

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

@@ -0,0 +1,621 @@
+/* tslint:disable:no-non-null-assertion */
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { pick } from '../../common/src/pick';
+import {
+    discountOnItemWithFacets,
+    orderPercentageDiscount,
+} from '../src/config/promotion/default-promotion-actions';
+import { atLeastNWithFacets, minimumOrderAmount } from '../src/config/promotion/default-promotion-conditions';
+
+import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import {
+    CreatePromotion,
+    CreatePromotionInput,
+    GetFacetList,
+    GetPromoProducts,
+} from './graphql/generated-e2e-admin-types';
+import {
+    AddItemToOrder,
+    AdjustItemQuantity,
+    ApplyCouponCode,
+    GetActiveOrder,
+    GetOrderPromotionsByCode,
+    RemoveCouponCode,
+    SetCustomerForOrder,
+} from './graphql/generated-e2e-shop-types';
+import { CREATE_PROMOTION, GET_FACET_LIST } from './graphql/shared-definitions';
+import {
+    ADD_ITEM_TO_ORDER,
+    ADJUST_ITEM_QUANTITY,
+    APPLY_COUPON_CODE,
+    GET_ACTIVE_ORDER,
+    GET_ORDER_PROMOTIONS_BY_CODE,
+    REMOVE_COUPON_CODE,
+    SET_CUSTOMER,
+} from './graphql/shop-definitions';
+import { TestAdminClient, TestShopClient } from './test-client';
+import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+import {
+    addPaymentToOrder,
+    proceedToArrangingPayment,
+    testSuccessfulPaymentMethod,
+} from './utils/test-order-utils';
+
+describe('Promotions applied to Orders', () => {
+    const adminClient = new TestAdminClient();
+    const shopClient = new TestShopClient();
+    const server = new TestServer();
+
+    const freeOrderAction = {
+        code: orderPercentageDiscount.code,
+        arguments: [{ name: 'discount', type: 'int', value: '100' }],
+    };
+    const minOrderAmountCondition = (min: number) => ({
+        code: minimumOrderAmount.code,
+        arguments: [
+            { name: 'amount', type: 'int', value: min.toString() },
+            { name: 'taxInclusive', type: 'boolean', value: 'true' },
+        ],
+    });
+
+    let products: GetPromoProducts.Items[];
+
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-promotions.csv'),
+                customerCount: 2,
+            },
+            {
+                paymentOptions: {
+                    paymentMethodHandlers: [testSuccessfulPaymentMethod],
+                },
+            },
+        );
+        await shopClient.init();
+        await adminClient.init();
+
+        await getProducts();
+        await createGlobalPromotions();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('coupon codes', () => {
+        const TEST_COUPON_CODE = 'TESTCOUPON';
+        const EXPIRED_COUPON_CODE = 'EXPIRED';
+        let promoFreeWithCoupon: CreatePromotion.CreatePromotion;
+        let promoFreeWithExpiredCoupon: CreatePromotion.CreatePromotion;
+
+        beforeAll(async () => {
+            promoFreeWithCoupon = await createPromotion({
+                enabled: true,
+                name: 'Free with test coupon',
+                couponCode: TEST_COUPON_CODE,
+                conditions: [],
+                actions: [freeOrderAction],
+            });
+            promoFreeWithExpiredCoupon = await createPromotion({
+                enabled: true,
+                name: 'Expired coupon',
+                endsAt: new Date(2010, 0, 0),
+                couponCode: EXPIRED_COUPON_CODE,
+                conditions: [],
+                actions: [freeOrderAction],
+            });
+
+            await shopClient.asAnonymousUser();
+            const item60 = getVariantBySlug('item-60');
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: item60.id,
+                quantity: 1,
+            });
+        });
+
+        afterAll(async () => {
+            await deletePromotion(promoFreeWithCoupon.id);
+            await deletePromotion(promoFreeWithExpiredCoupon.id);
+        });
+
+        it(
+            'applyCouponCode throws with nonexistant code',
+            assertThrowsWithMessage(async () => {
+                await shopClient.query<ApplyCouponCode.Mutation, ApplyCouponCode.Variables>(
+                    APPLY_COUPON_CODE,
+                    {
+                        couponCode: 'bad code',
+                    },
+                );
+            }, 'Coupon code "bad code" is not valid'),
+        );
+
+        it(
+            'applyCouponCode throws with expired code',
+            assertThrowsWithMessage(async () => {
+                await shopClient.query<ApplyCouponCode.Mutation, ApplyCouponCode.Variables>(
+                    APPLY_COUPON_CODE,
+                    {
+                        couponCode: EXPIRED_COUPON_CODE,
+                    },
+                );
+            }, `Coupon code "${EXPIRED_COUPON_CODE}" has expired`),
+        );
+
+        it('applies a valid coupon code', async () => {
+            const { applyCouponCode } = await shopClient.query<
+                ApplyCouponCode.Mutation,
+                ApplyCouponCode.Variables
+            >(APPLY_COUPON_CODE, {
+                couponCode: TEST_COUPON_CODE,
+            });
+
+            expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
+            expect(applyCouponCode!.adjustments.length).toBe(1);
+            expect(applyCouponCode!.adjustments[0].description).toBe('Free with test coupon');
+            expect(applyCouponCode!.total).toBe(0);
+        });
+
+        it('de-duplicates existing codes', async () => {
+            const { applyCouponCode } = await shopClient.query<
+                ApplyCouponCode.Mutation,
+                ApplyCouponCode.Variables
+            >(APPLY_COUPON_CODE, {
+                couponCode: TEST_COUPON_CODE,
+            });
+
+            expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
+        });
+
+        it('removes a coupon code', async () => {
+            const { removeCouponCode } = await shopClient.query<
+                RemoveCouponCode.Mutation,
+                RemoveCouponCode.Variables
+            >(REMOVE_COUPON_CODE, {
+                couponCode: TEST_COUPON_CODE,
+            });
+
+            expect(removeCouponCode!.adjustments.length).toBe(0);
+            expect(removeCouponCode!.total).toBe(6000);
+        });
+    });
+
+    describe('default PromotionConditions', () => {
+        beforeEach(async () => {
+            await shopClient.asAnonymousUser();
+        });
+
+        it('minimumOrderAmount', async () => {
+            const promotion = await createPromotion({
+                enabled: true,
+                name: 'Free if order total greater than 100',
+                conditions: [minOrderAmountCondition(10000)],
+                actions: [freeOrderAction],
+            });
+            const item60 = getVariantBySlug('item-60');
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: item60.id,
+                quantity: 1,
+            });
+            expect(addItemToOrder!.total).toBe(6000);
+            expect(addItemToOrder!.adjustments.length).toBe(0);
+
+            const { adjustOrderLine } = await shopClient.query<
+                AdjustItemQuantity.Mutation,
+                AdjustItemQuantity.Variables
+            >(ADJUST_ITEM_QUANTITY, {
+                orderLineId: addItemToOrder!.lines[0].id,
+                quantity: 2,
+            });
+            expect(adjustOrderLine!.total).toBe(0);
+            expect(adjustOrderLine!.adjustments[0].description).toBe('Free if order total greater than 100');
+            expect(adjustOrderLine!.adjustments[0].amount).toBe(-12000);
+
+            await deletePromotion(promotion.id);
+        });
+
+        it('atLeastNWithFacets', async () => {
+            const { facets } = await adminClient.query<GetFacetList.Query>(GET_FACET_LIST);
+            const saleFacetValue = facets.items[0].values[0];
+            const promotion = await createPromotion({
+                enabled: true,
+                name: 'Free if order contains 2 items with Sale facet value',
+                conditions: [
+                    {
+                        code: atLeastNWithFacets.code,
+                        arguments: [
+                            { name: 'minimum', type: 'int', value: '2' },
+                            { name: 'facets', type: 'facetValueIds', value: `["${saleFacetValue.id}"]` },
+                        ],
+                    },
+                ],
+                actions: [freeOrderAction],
+            });
+
+            const itemSale1 = getVariantBySlug('item-sale-1');
+            const itemSale12 = getVariantBySlug('item-sale-12');
+            const { addItemToOrder: res1 } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: itemSale1.id,
+                quantity: 1,
+            });
+            expect(res1!.total).toBe(120);
+            expect(res1!.adjustments.length).toBe(0);
+
+            const { addItemToOrder: res2 } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: itemSale12.id,
+                quantity: 1,
+            });
+            expect(res2!.total).toBe(0);
+            expect(res2!.adjustments.length).toBe(1);
+            expect(res2!.total).toBe(0);
+            expect(res2!.adjustments[0].description).toBe(
+                'Free if order contains 2 items with Sale facet value',
+            );
+            expect(res2!.adjustments[0].amount).toBe(-1320);
+
+            await deletePromotion(promotion.id);
+        });
+    });
+
+    describe('default PromotionActions', () => {
+        beforeEach(async () => {
+            await shopClient.asAnonymousUser();
+        });
+
+        it('orderPercentageDiscount', async () => {
+            const couponCode = '50%_off_order';
+            const promotion = await createPromotion({
+                enabled: true,
+                name: '50% discount on order',
+                couponCode,
+                conditions: [],
+                actions: [
+                    {
+                        code: orderPercentageDiscount.code,
+                        arguments: [{ name: 'discount', type: 'int', value: '50' }],
+                    },
+                ],
+            });
+            const item60 = getVariantBySlug('item-60');
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: item60.id,
+                quantity: 1,
+            });
+            expect(addItemToOrder!.total).toBe(6000);
+            expect(addItemToOrder!.adjustments.length).toBe(0);
+
+            const { applyCouponCode } = await shopClient.query<
+                ApplyCouponCode.Mutation,
+                ApplyCouponCode.Variables
+            >(APPLY_COUPON_CODE, {
+                couponCode,
+            });
+
+            expect(applyCouponCode!.adjustments.length).toBe(1);
+            expect(applyCouponCode!.adjustments[0].description).toBe('50% discount on order');
+            expect(applyCouponCode!.total).toBe(3000);
+
+            await deletePromotion(promotion.id);
+        });
+
+        it('discountOnItemWithFacets', async () => {
+            const { facets } = await adminClient.query<GetFacetList.Query>(GET_FACET_LIST);
+            const saleFacetValue = facets.items[0].values[0];
+            const couponCode = '50%_off_sale_items';
+            const promotion = await createPromotion({
+                enabled: true,
+                name: '50% off sale items',
+                couponCode,
+                conditions: [],
+                actions: [
+                    {
+                        code: discountOnItemWithFacets.code,
+                        arguments: [
+                            { name: 'discount', type: 'int', value: '50' },
+                            { name: 'facets', type: 'facetValueIds', value: `["${saleFacetValue.id}"]` },
+                        ],
+                    },
+                ],
+            });
+            await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+                productVariantId: getVariantBySlug('item-12').id,
+                quantity: 1,
+            });
+            await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+                productVariantId: getVariantBySlug('item-sale-12').id,
+                quantity: 1,
+            });
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: getVariantBySlug('item-sale-1').id,
+                quantity: 2,
+            });
+            expect(addItemToOrder!.adjustments.length).toBe(0);
+            expect(addItemToOrder!.total).toBe(2640);
+
+            const { applyCouponCode } = await shopClient.query<
+                ApplyCouponCode.Mutation,
+                ApplyCouponCode.Variables
+            >(APPLY_COUPON_CODE, {
+                couponCode,
+            });
+
+            expect(applyCouponCode!.total).toBe(1920);
+
+            await deletePromotion(promotion.id);
+        });
+    });
+
+    describe('per-customer usage limit', () => {
+        const TEST_COUPON_CODE = 'TESTCOUPON';
+        let promoWithUsageLimit: CreatePromotion.CreatePromotion;
+
+        beforeAll(async () => {
+            promoWithUsageLimit = await createPromotion({
+                enabled: true,
+                name: 'Free with test coupon',
+                couponCode: TEST_COUPON_CODE,
+                perCustomerUsageLimit: 1,
+                conditions: [],
+                actions: [freeOrderAction],
+            });
+        });
+
+        afterAll(async () => {
+            await deletePromotion(promoWithUsageLimit.id);
+        });
+
+        async function createNewActiveOrder() {
+            const item60 = getVariantBySlug('item-60');
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: item60.id,
+                quantity: 1,
+            });
+            return addItemToOrder;
+        }
+
+        describe('guest customer', () => {
+            const GUEST_EMAIL_ADDRESS = 'guest@test.com';
+            let orderCode: string;
+
+            function addGuestCustomerToOrder() {
+                return shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(
+                    SET_CUSTOMER,
+                    {
+                        input: {
+                            emailAddress: GUEST_EMAIL_ADDRESS,
+                            firstName: 'Guest',
+                            lastName: 'Customer',
+                        },
+                    },
+                );
+            }
+
+            it('allows initial usage', async () => {
+                await shopClient.asAnonymousUser();
+                await createNewActiveOrder();
+                await addGuestCustomerToOrder();
+
+                const { applyCouponCode } = await shopClient.query<
+                    ApplyCouponCode.Mutation,
+                    ApplyCouponCode.Variables
+                >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
+
+                expect(applyCouponCode!.total).toBe(0);
+                expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
+
+                await proceedToArrangingPayment(shopClient);
+                const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+                expect(order.state).toBe('PaymentSettled');
+                expect(order.active).toBe(false);
+                orderCode = order.code;
+            });
+
+            it('adds Promotions to Order once payment arranged', async () => {
+                const { orderByCode } = await shopClient.query<
+                    GetOrderPromotionsByCode.Query,
+                    GetOrderPromotionsByCode.Variables
+                >(GET_ORDER_PROMOTIONS_BY_CODE, {
+                    code: orderCode,
+                });
+                expect(orderByCode!.promotions.map(pick(['id', 'name']))).toEqual([
+                    { id: 'T_9', name: 'Free with test coupon' },
+                ]);
+            });
+
+            it('throws when usage exceeds limit', async () => {
+                await shopClient.asAnonymousUser();
+                await createNewActiveOrder();
+                await addGuestCustomerToOrder();
+
+                try {
+                    await shopClient.query<ApplyCouponCode.Mutation, ApplyCouponCode.Variables>(
+                        APPLY_COUPON_CODE,
+                        { couponCode: TEST_COUPON_CODE },
+                    );
+                    fail('should have thrown');
+                } catch (err) {
+                    expect(err.message).toEqual(
+                        expect.stringContaining('Coupon code cannot be used more than once per customer'),
+                    );
+                }
+            });
+
+            it('removes couponCode from order when adding customer after code applied', async () => {
+                await shopClient.asAnonymousUser();
+                await createNewActiveOrder();
+
+                const { applyCouponCode } = await shopClient.query<
+                    ApplyCouponCode.Mutation,
+                    ApplyCouponCode.Variables
+                >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
+
+                expect(applyCouponCode!.total).toBe(0);
+                expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
+
+                await addGuestCustomerToOrder();
+
+                const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
+                expect(activeOrder!.couponCodes).toEqual([]);
+                expect(activeOrder!.total).toBe(6000);
+            });
+        });
+
+        describe('signed-in customer', () => {
+            function logInAsRegisteredCustomer() {
+                return shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+            }
+
+            it('allows initial usage', async () => {
+                await logInAsRegisteredCustomer();
+                await createNewActiveOrder();
+                const { applyCouponCode } = await shopClient.query<
+                    ApplyCouponCode.Mutation,
+                    ApplyCouponCode.Variables
+                >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
+
+                expect(applyCouponCode!.total).toBe(0);
+                expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
+
+                await proceedToArrangingPayment(shopClient);
+                const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+                expect(order.state).toBe('PaymentSettled');
+                expect(order.active).toBe(false);
+            });
+
+            it('throws when usage exceeds limit', async () => {
+                await logInAsRegisteredCustomer();
+                await createNewActiveOrder();
+                try {
+                    await shopClient.query<ApplyCouponCode.Mutation, ApplyCouponCode.Variables>(
+                        APPLY_COUPON_CODE,
+                        { couponCode: TEST_COUPON_CODE },
+                    );
+                    fail('should have thrown');
+                } catch (err) {
+                    expect(err.message).toEqual(
+                        expect.stringContaining('Coupon code cannot be used more than once per customer'),
+                    );
+                }
+            });
+
+            it('removes couponCode from order when logging in after code applied', async () => {
+                await shopClient.asAnonymousUser();
+                await createNewActiveOrder();
+                const { applyCouponCode } = await shopClient.query<
+                    ApplyCouponCode.Mutation,
+                    ApplyCouponCode.Variables
+                >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
+
+                expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
+                expect(applyCouponCode!.total).toBe(0);
+
+                await logInAsRegisteredCustomer();
+
+                const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
+                expect(activeOrder!.total).toBe(6000);
+                expect(activeOrder!.couponCodes).toEqual([]);
+            });
+        });
+    });
+
+    async function getProducts() {
+        const result = await adminClient.query<GetPromoProducts.Query>(GET_PROMO_PRODUCTS, {
+            options: {
+                take: 10,
+                skip: 0,
+            },
+        });
+        products = result.products.items;
+    }
+    async function createGlobalPromotions() {
+        const { facets } = await adminClient.query<GetFacetList.Query>(GET_FACET_LIST);
+        const saleFacetValue = facets.items[0].values[0];
+        await createPromotion({
+            enabled: true,
+            name: 'Promo not yet started',
+            startsAt: new Date(2199, 0, 0),
+            conditions: [minOrderAmountCondition(100)],
+            actions: [freeOrderAction],
+        });
+
+        const deletedPromotion = await createPromotion({
+            enabled: true,
+            name: 'Deleted promotion',
+            conditions: [minOrderAmountCondition(100)],
+            actions: [freeOrderAction],
+        });
+        await deletePromotion(deletedPromotion.id);
+    }
+
+    async function createPromotion(input: CreatePromotionInput): Promise<CreatePromotion.CreatePromotion> {
+        const result = await adminClient.query<CreatePromotion.Mutation, CreatePromotion.Variables>(
+            CREATE_PROMOTION,
+            {
+                input,
+            },
+        );
+        return result.createPromotion;
+    }
+
+    function getVariantBySlug(
+        slug: 'item-1' | 'item-12' | 'item-60' | 'item-sale-1' | 'item-sale-12',
+    ): GetPromoProducts.Variants {
+        return products.find(p => p.slug === slug)!.variants[0];
+    }
+
+    async function deletePromotion(promotionId: string) {
+        await adminClient.query(gql`
+            mutation DeletePromotionAdHoc1 {
+                deletePromotion(id: "${promotionId}") {
+                    result
+                }
+            }
+        `);
+    }
+});
+
+export const GET_PROMO_PRODUCTS = gql`
+    query GetPromoProducts {
+        products {
+            items {
+                id
+                slug
+                variants {
+                    id
+                    price
+                    priceWithTax
+                    sku
+                    facetValues {
+                        id
+                        code
+                    }
+                }
+            }
+        }
+    }
+`;

+ 3 - 64
packages/core/e2e/order.e2e-spec.ts

@@ -5,7 +5,6 @@ import path from 'path';
 
 import { HistoryEntryType, StockMovementType } from '../../common/lib/generated-types';
 import { pick } from '../../common/lib/pick';
-import { ID } from '../../common/lib/shared-types';
 import { PaymentMethodHandler } from '../src/config/payment-method/payment-method-handler';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
@@ -29,32 +28,18 @@ import {
     SettleRefund,
     UpdateProductVariants,
 } from './graphql/generated-e2e-admin-types';
-import {
-    AddItemToOrder,
-    AddPaymentToOrder,
-    AddPaymentToOrderMutation,
-    GetShippingMethods,
-    SetShippingAddress,
-    SetShippingMethod,
-    TransitionToState,
-} from './graphql/generated-e2e-shop-types';
+import { AddItemToOrder } from './graphql/generated-e2e-shop-types';
 import {
     GET_CUSTOMER_LIST,
     GET_PRODUCT_WITH_VARIANTS,
     GET_STOCK_MOVEMENT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
-import {
-    ADD_ITEM_TO_ORDER,
-    ADD_PAYMENT,
-    GET_ELIGIBLE_SHIPPING_METHODS,
-    SET_SHIPPING_ADDRESS,
-    SET_SHIPPING_METHOD,
-    TRANSITION_TO_STATE,
-} from './graphql/shop-definitions';
+import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
 import { TestAdminClient, TestShopClient } from './test-client';
 import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
 
 describe('Orders resolver', () => {
     const adminClient = new TestAdminClient();
@@ -1216,33 +1201,6 @@ const failsToSettlePaymentMethod = new PaymentMethodHandler({
     },
 });
 
-async function proceedToArrangingPayment(shopClient: TestShopClient): Promise<ID> {
-    await shopClient.query<SetShippingAddress.Mutation, SetShippingAddress.Variables>(SET_SHIPPING_ADDRESS, {
-        input: {
-            fullName: 'name',
-            streetLine1: '12 the street',
-            city: 'foo',
-            postalCode: '123456',
-            countryCode: 'US',
-        },
-    });
-
-    const { eligibleShippingMethods } = await shopClient.query<GetShippingMethods.Query>(
-        GET_ELIGIBLE_SHIPPING_METHODS,
-    );
-
-    await shopClient.query<SetShippingMethod.Mutation, SetShippingMethod.Variables>(SET_SHIPPING_METHOD, {
-        id: eligibleShippingMethods[1].id,
-    });
-
-    const { transitionOrderToState } = await shopClient.query<
-        TransitionToState.Mutation,
-        TransitionToState.Variables
-    >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
-
-    return transitionOrderToState!.id;
-}
-
 async function createTestOrder(
     adminClient: TestAdminClient,
     shopClient: TestShopClient,
@@ -1288,25 +1246,6 @@ async function createTestOrder(
     return { product, productVariantId, orderId };
 }
 
-async function addPaymentToOrder(
-    shopClient: TestShopClient,
-    handler: PaymentMethodHandler,
-): Promise<NonNullable<AddPaymentToOrder.Mutation['addPaymentToOrder']>> {
-    const result = await shopClient.query<AddPaymentToOrder.Mutation, AddPaymentToOrder.Variables>(
-        ADD_PAYMENT,
-        {
-            input: {
-                method: handler.code,
-                metadata: {
-                    baz: 'quux',
-                },
-            },
-        },
-    );
-    const order = result.addPaymentToOrder!;
-    return order as any;
-}
-
 export const GET_ORDERS_LIST = gql`
     query GetOrderList($options: OrderListOptions) {
         orders(options: $options) {

+ 54 - 12
packages/core/e2e/promotion.e2e-spec.ts

@@ -18,6 +18,7 @@ import {
     Promotion,
     UpdatePromotion,
 } from './graphql/generated-e2e-admin-types';
+import { CREATE_PROMOTION } from './graphql/shared-definitions';
 import { TestAdminClient } from './test-client';
 import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
@@ -33,9 +34,15 @@ describe('Promotion resolver', () => {
 
     const promoAction = generateTestAction('promo_action');
 
-    const snapshotProps = ['name', 'actions', 'conditions', 'enabled'] as Array<
-        'name' | 'actions' | 'conditions' | 'enabled'
-    >;
+    const snapshotProps: Array<keyof Promotion.Fragment> = [
+        'name',
+        'actions',
+        'conditions',
+        'enabled',
+        'couponCode',
+        'startsAt',
+        'endsAt',
+    ];
     let promotion: Promotion.Fragment;
 
     beforeAll(async () => {
@@ -65,6 +72,9 @@ describe('Promotion resolver', () => {
                 input: {
                     name: 'test promotion',
                     enabled: true,
+                    couponCode: 'TEST123',
+                    startsAt: new Date(2019, 9, 30),
+                    endsAt: new Date(2019, 11, 1),
                     conditions: [
                         {
                             code: promoCondition.code,
@@ -90,12 +100,40 @@ describe('Promotion resolver', () => {
         expect(pick(promotion, snapshotProps)).toMatchSnapshot();
     });
 
+    it(
+        'createPromotion throws with empty conditions and no couponCode',
+        assertThrowsWithMessage(async () => {
+            await client.query<CreatePromotion.Mutation, CreatePromotion.Variables>(CREATE_PROMOTION, {
+                input: {
+                    name: 'bad promotion',
+                    enabled: true,
+                    conditions: [],
+                    actions: [
+                        {
+                            code: promoAction.code,
+                            arguments: [
+                                {
+                                    name: 'facetValueIds',
+                                    value: '["T_1"]',
+                                    type: 'facetValueIds',
+                                },
+                            ],
+                        },
+                    ],
+                },
+            });
+        }, 'A Promotion must have either at least one condition or a coupon code set'),
+    );
+
     it('updatePromotion', async () => {
         const result = await client.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(
             UPDATE_PROMOTION,
             {
                 input: {
                     id: promotion.id,
+                    couponCode: 'TEST1235',
+                    startsAt: new Date(2019, 4, 30),
+                    endsAt: new Date(2019, 5, 1),
                     conditions: [
                         {
                             code: promoCondition.code,
@@ -112,6 +150,19 @@ describe('Promotion resolver', () => {
         expect(pick(result.updatePromotion, snapshotProps)).toMatchSnapshot();
     });
 
+    it(
+        'updatePromotion throws with empty conditions and no couponCode',
+        assertThrowsWithMessage(async () => {
+            await client.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(UPDATE_PROMOTION, {
+                input: {
+                    id: promotion.id,
+                    couponCode: '',
+                    conditions: [],
+                },
+            });
+        }, 'A Promotion must have either at least one condition or a coupon code set'),
+    );
+
     it('promotion', async () => {
         const result = await client.query<GetPromotion.Query, GetPromotion.Variables>(GET_PROMOTION, {
             id: promotion.id,
@@ -238,15 +289,6 @@ export const GET_PROMOTION = gql`
     ${PROMOTION_FRAGMENT}
 `;
 
-export const CREATE_PROMOTION = gql`
-    mutation CreatePromotion($input: CreatePromotionInput!) {
-        createPromotion(input: $input) {
-            ...Promotion
-        }
-    }
-    ${PROMOTION_FRAGMENT}
-`;
-
 export const UPDATE_PROMOTION = gql`
     mutation UpdatePromotion($input: UpdatePromotionInput!) {
         updatePromotion(input: $input) {

+ 7 - 23
packages/core/e2e/shop-order.e2e-spec.ts

@@ -55,6 +55,7 @@ import {
 import { TestAdminClient, TestShopClient } from './test-client';
 import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+import { testSuccessfulPaymentMethod } from './utils/test-order-utils';
 
 describe('Shop orders', () => {
     const adminClient = new TestAdminClient();
@@ -70,7 +71,7 @@ describe('Shop orders', () => {
             {
                 paymentOptions: {
                     paymentMethodHandlers: [
-                        testPaymentMethod,
+                        testSuccessfulPaymentMethod,
                         testFailingPaymentMethod,
                         testErrorPaymentMethod,
                     ],
@@ -399,7 +400,7 @@ describe('Shop orders', () => {
                 AddPaymentToOrder.Variables
             >(ADD_PAYMENT, {
                 input: {
-                    method: testPaymentMethod.code,
+                    method: testSuccessfulPaymentMethod.code,
                     metadata: {},
                 },
             });
@@ -408,7 +409,7 @@ describe('Shop orders', () => {
             expect(addPaymentToOrder!.state).toBe('PaymentSettled');
             expect(addPaymentToOrder!.active).toBe(false);
             expect(addPaymentToOrder!.payments!.length).toBe(1);
-            expect(payment.method).toBe(testPaymentMethod.code);
+            expect(payment.method).toBe(testSuccessfulPaymentMethod.code);
             expect(payment.state).toBe('Settled');
         });
 
@@ -660,7 +661,7 @@ describe('Shop orders', () => {
                             ADD_PAYMENT,
                             {
                                 input: {
-                                    method: testPaymentMethod.code,
+                                    method: testSuccessfulPaymentMethod.code,
                                     metadata: {},
                                 },
                             },
@@ -796,7 +797,7 @@ describe('Shop orders', () => {
                     AddPaymentToOrder.Variables
                 >(ADD_PAYMENT, {
                     input: {
-                        method: testPaymentMethod.code,
+                        method: testSuccessfulPaymentMethod.code,
                         metadata: {
                             baz: 'quux',
                         },
@@ -807,7 +808,7 @@ describe('Shop orders', () => {
                 expect(addPaymentToOrder!.state).toBe('PaymentSettled');
                 expect(addPaymentToOrder!.active).toBe(false);
                 expect(addPaymentToOrder!.payments!.length).toBe(3);
-                expect(payment.method).toBe(testPaymentMethod.code);
+                expect(payment.method).toBe(testSuccessfulPaymentMethod.code);
                 expect(payment.state).toBe('Settled');
                 expect(payment.transactionId).toBe('12345');
                 expect(payment.metadata).toEqual({
@@ -867,23 +868,6 @@ describe('Shop orders', () => {
     });
 });
 
-const testPaymentMethod = new PaymentMethodHandler({
-    code: 'test-payment-method',
-    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
-    args: {},
-    createPayment: (order, args, metadata) => {
-        return {
-            amount: order.total,
-            state: 'Settled',
-            transactionId: '12345',
-            metadata,
-        };
-    },
-    settlePayment: order => ({
-        success: true,
-    }),
-});
-
 const testFailingPaymentMethod = new PaymentMethodHandler({
     code: 'test-failing-payment-method',
     description: [{ languageCode: LanguageCode.en, value: 'Test Failing Payment Method' }],

+ 82 - 0
packages/core/e2e/utils/test-order-utils.ts

@@ -0,0 +1,82 @@
+/* tslint:disable:no-non-null-assertion */
+import { ID } from '../../../common/lib/shared-types';
+import { PaymentMethodHandler } from '../../src/config/payment-method/payment-method-handler';
+import { LanguageCode } from '../graphql/generated-e2e-admin-types';
+import {
+    AddPaymentToOrder,
+    GetShippingMethods,
+    SetShippingAddress,
+    SetShippingMethod,
+    TransitionToState,
+} from '../graphql/generated-e2e-shop-types';
+import {
+    ADD_PAYMENT,
+    GET_ELIGIBLE_SHIPPING_METHODS,
+    SET_SHIPPING_ADDRESS,
+    SET_SHIPPING_METHOD,
+    TRANSITION_TO_STATE,
+} from '../graphql/shop-definitions';
+import { TestShopClient } from '../test-client';
+
+export async function proceedToArrangingPayment(shopClient: TestShopClient): Promise<ID> {
+    await shopClient.query<SetShippingAddress.Mutation, SetShippingAddress.Variables>(SET_SHIPPING_ADDRESS, {
+        input: {
+            fullName: 'name',
+            streetLine1: '12 the street',
+            city: 'foo',
+            postalCode: '123456',
+            countryCode: 'US',
+        },
+    });
+
+    const { eligibleShippingMethods } = await shopClient.query<GetShippingMethods.Query>(
+        GET_ELIGIBLE_SHIPPING_METHODS,
+    );
+
+    await shopClient.query<SetShippingMethod.Mutation, SetShippingMethod.Variables>(SET_SHIPPING_METHOD, {
+        id: eligibleShippingMethods[1].id,
+    });
+
+    const { transitionOrderToState } = await shopClient.query<
+        TransitionToState.Mutation,
+        TransitionToState.Variables
+    >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
+
+    return transitionOrderToState!.id;
+}
+
+export async function addPaymentToOrder(
+    shopClient: TestShopClient,
+    handler: PaymentMethodHandler,
+): Promise<NonNullable<AddPaymentToOrder.Mutation['addPaymentToOrder']>> {
+    const result = await shopClient.query<AddPaymentToOrder.Mutation, AddPaymentToOrder.Variables>(
+        ADD_PAYMENT,
+        {
+            input: {
+                method: handler.code,
+                metadata: {
+                    baz: 'quux',
+                },
+            },
+        },
+    );
+    const order = result.addPaymentToOrder!;
+    return order as any;
+}
+
+export const testSuccessfulPaymentMethod = new PaymentMethodHandler({
+    code: 'test-payment-method',
+    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Settled',
+            transactionId: '12345',
+            metadata,
+        };
+    },
+    settlePayment: order => ({
+        success: true,
+    }),
+});

+ 13 - 3
packages/core/src/api/resolvers/entity/order-entity.resolver.ts

@@ -8,9 +8,11 @@ import { ShippingMethodService } from '../../../service/services/shipping-method
 
 @Resolver('Order')
 export class OrderEntityResolver {
-    constructor(private orderService: OrderService,
-                private shippingMethodService: ShippingMethodService,
-                private historyService: HistoryService) {}
+    constructor(
+        private orderService: OrderService,
+        private shippingMethodService: ShippingMethodService,
+        private historyService: HistoryService,
+    ) {}
 
     @ResolveProperty()
     async payments(@Parent() order: Order) {
@@ -41,4 +43,12 @@ export class OrderEntityResolver {
     async history(@Parent() order: Order, @Args() args: OrderHistoryArgs) {
         return this.historyService.getHistoryForOrder(order.id, args.options || undefined);
     }
+
+    @ResolveProperty()
+    async promotions(@Parent() order: Order) {
+        if (order.promotions) {
+            return order.promotions;
+        }
+        return this.orderService.getOrderPromotions(order.id);
+    }
 }

+ 21 - 0
packages/core/src/api/resolvers/shop/shop-order.resolver.ts

@@ -3,6 +3,7 @@ import {
     MutationAddItemToOrderArgs,
     MutationAddPaymentToOrderArgs,
     MutationAdjustOrderLineArgs,
+    MutationApplyCouponCodeArgs,
     MutationRemoveOrderLineArgs,
     MutationSetCustomerForOrderArgs,
     MutationSetOrderShippingAddressArgs,
@@ -225,6 +226,26 @@ export class ShopOrderResolver {
         return this.orderService.removeItemFromOrder(ctx, order.id, args.orderLineId);
     }
 
+    @Mutation()
+    @Allow(Permission.UpdateOrder, Permission.Owner)
+    async applyCouponCode(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationApplyCouponCodeArgs,
+    ): Promise<Order> {
+        const order = await this.getOrderFromContext(ctx, true);
+        return this.orderService.applyCouponCode(ctx, order.id, args.couponCode);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateOrder, Permission.Owner)
+    async removeCouponCode(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationApplyCouponCodeArgs,
+    ): Promise<Order> {
+        const order = await this.getOrderFromContext(ctx, true);
+        return this.orderService.removeCouponCode(ctx, order.id, args.couponCode);
+    }
+
     @Mutation()
     @Allow(Permission.UpdateOrder, Permission.Owner)
     async addPaymentToOrder(@Ctx() ctx: RequestContext, @Args() args: MutationAddPaymentToOrderArgs) {

+ 8 - 0
packages/core/src/api/schema/admin-api/promotion.api.graphql

@@ -17,6 +17,10 @@ input PromotionListOptions
 input CreatePromotionInput {
     name: String!
     enabled: Boolean!
+    startsAt: DateTime
+    endsAt: DateTime
+    couponCode: String
+    perCustomerUsageLimit: Int
     conditions: [ConfigurableOperationInput!]!
     actions: [ConfigurableOperationInput!]!
 }
@@ -25,6 +29,10 @@ input UpdatePromotionInput {
     id: ID!
     name: String
     enabled: Boolean
+    startsAt: DateTime
+    endsAt: DateTime
+    couponCode: String
+    perCustomerUsageLimit: Int
     conditions: [ConfigurableOperationInput!]
     actions: [ConfigurableOperationInput!]
 }

+ 4 - 0
packages/core/src/api/schema/shop-api/shop.api.graphql

@@ -22,6 +22,10 @@ type Mutation {
     removeOrderLine(orderLineId: ID!): Order
     "Adjusts an OrderLine. If custom fields are defined on the OrderLine entity, a third argument 'customFields' will be available."
     adjustOrderLine(orderLineId: ID!, quantity: Int): Order
+    "Applies the given coupon code to the active Order"
+    applyCouponCode(couponCode: String!): Order
+    "Removes the given coupon code from the active Order"
+    removeCouponCode(couponCode: String!): Order
     transitionOrderToState(state: String!): Order
     setOrderShippingAddress(input: CreateAddressInput!): Order
     setOrderShippingMethod(shippingMethodId: ID!): Order

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

@@ -2,17 +2,23 @@ type Order implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
+    "A unique code for the Order"
     code: String!
     state: String!
+    "An order is active as long as the payment process has not been completed"
     active: Boolean!
     customer: Customer
     shippingAddress: OrderAddress
     billingAddress: OrderAddress
     lines: [OrderLine!]!
+    "Order-level adjustments to the order total, such as discounts from promotions"
     adjustments: [Adjustment!]!
+    couponCodes: [String!]!
+    promotions: [Promotion!]!
     payments: [Payment!]
     fulfillments: [Fulfillment!]
     subTotalBeforeTax: Int!
+    "The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied."
     subTotal: Int!
     currencyCode: CurrencyCode!
     shipping: Int!

+ 4 - 0
packages/core/src/api/schema/type/promotion.type.graphql

@@ -2,6 +2,10 @@ type Promotion implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
+    startsAt: DateTime
+    endsAt: DateTime
+    couponCode: String
+    perCustomerUsageLimit: Int
     name: String!
     enabled: Boolean!
     conditions: [ConfigurableOperation!]!

+ 39 - 0
packages/core/src/common/error/errors.ts

@@ -208,3 +208,42 @@ export class OrderItemsLimitError extends I18nError {
         super('error.order-items-limit-exceeded', { maxItems }, 'ORDER_ITEMS_LIMIT_EXCEEDED');
     }
 }
+
+/**
+ * @description
+ * This error is thrown when the coupon code is not associated with any active Promotion.
+ *
+ * @docsCategory errors
+ * @docsPage Error Types
+ */
+export class CouponCodeInvalidError extends I18nError {
+    constructor(couponCode: string) {
+        super('error.coupon-code-not-valid', { couponCode }, 'COUPON_CODE_INVALID');
+    }
+}
+
+/**
+ * @description
+ * This error is thrown when the coupon code is associated with a Promotion that has expired.
+ *
+ * @docsCategory errors
+ * @docsPage Error Types
+ */
+export class CouponCodeExpiredError extends I18nError {
+    constructor(couponCode: string) {
+        super('error.coupon-code-expired', { couponCode }, 'COUPON_CODE_EXPIRED');
+    }
+}
+
+/**
+ * @description
+ * This error is thrown when the coupon code is associated with a Promotion that has expired.
+ *
+ * @docsCategory errors
+ * @docsPage Error Types
+ */
+export class CouponCodeLimitError extends I18nError {
+    constructor(limit: number) {
+        super('error.coupon-code-limit-has-been-reached', { limit }, 'COUPON_CODE_LIMIT_REACHED');
+    }
+}

+ 9 - 0
packages/core/src/common/types/adjustment-source.ts

@@ -1,4 +1,5 @@
 import { Adjustment, AdjustmentType } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
 
 import { VendureEntity } from '../../entity/base/base.entity';
 
@@ -9,6 +10,14 @@ export abstract class AdjustmentSource extends VendureEntity {
         return `${this.type}:${this.id}`;
     }
 
+    static decodeSourceId(sourceId: string): { type: AdjustmentType; id: ID } {
+        const [type, id] = sourceId.split(':');
+        return {
+            type: type as AdjustmentType,
+            id: Number.isNaN(+id) ? id : +id,
+        };
+    }
+
     abstract test(...args: any[]): boolean | Promise<boolean>;
     abstract apply(...args: any[]): Adjustment | undefined | Promise<Adjustment | undefined>;
 }

+ 1 - 37
packages/core/src/config/promotion/default-promotion-actions.ts

@@ -18,37 +18,6 @@ export const orderPercentageDiscount = new PromotionOrderAction({
     description: [{ languageCode: LanguageCode.en, value: 'Discount order by { discount }%' }],
 });
 
-export const itemPercentageDiscount = new PromotionItemAction({
-    code: 'item_percentage_discount',
-    args: {
-        discount: {
-            type: 'int',
-            config: {
-                inputType: 'percentage',
-            },
-        },
-    },
-    execute(orderItem, orderLine, args) {
-        return -orderLine.unitPrice * (args.discount / 100);
-    },
-    description: [{ languageCode: LanguageCode.en, value: 'Discount every item by { discount }%' }],
-});
-
-export const buy1Get1Free = new PromotionItemAction({
-    code: 'buy_1_get_1_free',
-    args: {},
-    execute(orderItem, orderLine, args) {
-        if (orderLine.quantity >= 2) {
-            const lineIndex = orderLine.items.indexOf(orderItem) + 1;
-            if (lineIndex % 2 === 0) {
-                return -orderLine.unitPrice;
-            }
-        }
-        return 0;
-    },
-    description: [{ languageCode: LanguageCode.en, value: 'Buy 1 get 1 free' }],
-});
-
 export const discountOnItemWithFacets = new PromotionItemAction({
     code: 'facet_based_discount',
     args: {
@@ -73,9 +42,4 @@ export const discountOnItemWithFacets = new PromotionItemAction({
     ],
 });
 
-export const defaultPromotionActions = [
-    orderPercentageDiscount,
-    itemPercentageDiscount,
-    buy1Get1Free,
-    discountOnItemWithFacets,
-];
+export const defaultPromotionActions = [orderPercentageDiscount, discountOnItemWithFacets];

+ 1 - 33
packages/core/src/config/promotion/default-promotion-conditions.ts

@@ -21,33 +21,6 @@ export const minimumOrderAmount = new PromotionCondition({
     priorityValue: 10,
 });
 
-export const dateRange = new PromotionCondition({
-    code: 'date_range',
-    description: [{ languageCode: LanguageCode.en, value: 'If Order placed between { start } and { end }' }],
-    args: {
-        start: { type: 'datetime' },
-        end: { type: 'datetime' },
-    },
-    check(order: Order, args) {
-        const now = new Date();
-        return args.start < now && now < args.end;
-    },
-});
-
-export const atLeastNOfProduct = new PromotionCondition({
-    code: 'at_least_n_of_product',
-    description: [{ languageCode: LanguageCode.en, value: 'Buy at least { minimum } of any product' }],
-    args: { minimum: { type: 'int' } },
-    check(order: Order, args) {
-        return order.lines.reduce(
-            (result, item) => {
-                return result || item.quantity >= args.minimum;
-            },
-            false as boolean,
-        );
-    },
-});
-
 export const atLeastNWithFacets = new PromotionCondition({
     code: 'at_least_n_with_facets',
     description: [
@@ -68,9 +41,4 @@ export const atLeastNWithFacets = new PromotionCondition({
     },
 });
 
-export const defaultPromotionConditions = [
-    minimumOrderAmount,
-    dateRange,
-    atLeastNOfProduct,
-    atLeastNWithFacets,
-];
+export const defaultPromotionConditions = [minimumOrderAmount, atLeastNWithFacets];

+ 4 - 1
packages/core/src/entity/order-item/order-item.entity.ts

@@ -65,6 +65,9 @@ export class OrderItem extends VendureEntity {
         }
     }
 
+    /**
+     * Adjustments with promotion values adjusted to include tax.
+     */
     @Calculated()
     get adjustments(): Adjustment[] {
         if (this.unitPriceIncludesTax) {
@@ -108,7 +111,7 @@ export class OrderItem extends VendureEntity {
     }
 
     get promotionAdjustmentsTotal(): number {
-        return this.adjustments
+        return this.pendingAdjustments
             .filter(a => a.type === AdjustmentType.PROMOTION)
             .reduce((total, a) => total + a.amount, 0);
     }

+ 14 - 1
packages/core/src/entity/order/order.entity.ts

@@ -1,6 +1,6 @@
 import { Adjustment, AdjustmentType, CurrencyCode, OrderAddress } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
-import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
+import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
@@ -12,6 +12,7 @@ import { EntityId } from '../entity-id.decorator';
 import { OrderItem } from '../order-item/order-item.entity';
 import { OrderLine } from '../order-line/order-line.entity';
 import { Payment } from '../payment/payment.entity';
+import { Promotion } from '../promotion/promotion.entity';
 import { ShippingMethod } from '../shipping-method/shipping-method.entity';
 
 /**
@@ -47,6 +48,13 @@ export class Order extends VendureEntity implements HasCustomFields {
     @OneToMany(type => OrderLine, line => line.order)
     lines: OrderLine[];
 
+    @Column('simple-array')
+    couponCodes: string[];
+
+    @ManyToMany(type => Promotion)
+    @JoinTable()
+    promotions: Promotion[];
+
     @Column('simple-json') pendingAdjustments: Adjustment[];
 
     @Column('simple-json') shippingAddress: OrderAddress;
@@ -61,6 +69,11 @@ export class Order extends VendureEntity implements HasCustomFields {
 
     @Column() subTotalBeforeTax: number;
 
+    /**
+     * @description
+     * The subTotal is the total of the OrderLines, before order-level promotions
+     * and shipping has been applied.
+     */
     @Column() subTotal: number;
 
     @EntityId({ nullable: true })

+ 21 - 0
packages/core/src/entity/promotion/promotion.entity.ts

@@ -62,6 +62,18 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
     @Column({ type: Date, nullable: true, default: null })
     deletedAt: Date | null;
 
+    @Column({ type: Date, nullable: true, default: null })
+    startsAt: Date | null;
+
+    @Column({ type: Date, nullable: true, default: null })
+    endsAt: Date | null;
+
+    @Column({ nullable: true, default: null })
+    couponCode: string;
+
+    @Column({ nullable: true, default: null })
+    perCustomerUsageLimit: number;
+
     @Column() name: string;
 
     @Column() enabled: boolean;
@@ -119,6 +131,15 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
     }
 
     async test(order: Order, utils: PromotionUtils): Promise<boolean> {
+        if (this.endsAt && this.endsAt < new Date()) {
+            return false;
+        }
+        if (this.startsAt && this.startsAt > new Date()) {
+            return false;
+        }
+        if (this.couponCode && !order.couponCodes.includes(this.couponCode)) {
+            return false;
+        }
         for (const condition of this.conditions) {
             const promotionCondition = this.allConditions[condition.code];
             if (!promotionCondition || !(await promotionCondition.check(order, condition.args, utils))) {

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

@@ -14,6 +14,9 @@
     "cannot-transition-to-payment-without-customer": "Cannot transition Order to the \"ArrangingPayment\" state without Customer details",
     "channel-not-found":  "No channel with the token \"{ token }\" exists",
     "country-code-not-valid":  "The countryCode \"{ countryCode }\" was not recognized",
+    "coupon-code-expired":  "Coupon code \"{ couponCode }\" has expired",
+    "coupon-code-limit-has-been-reached":  "Coupon code cannot be used more than {limit, plural, one {once} other {# times}} per customer",
+    "coupon-code-not-valid":  "Coupon code \"{ couponCode }\" is not valid",
     "create-fulfillment-items-already-fulfilled": "One or more OrderItems have already been fulfilled",
     "create-fulfillment-orders-must-be-settled": "One or more OrderItems belong to an Order which is in an invalid state",
     "create-fulfillment-nothing-to-fulfill": "Nothing to fulfill",
@@ -47,6 +50,7 @@
     "product-id-slug-mismatch": "The provided id and slug refer to different Products",
     "product-variant-option-ids-not-compatible": "ProductVariant optionIds must include one optionId from each of the groups: {groupNames}",
     "product-variant-options-combination-already-exists": "A ProductVariant already exists with the options: {optionNames}",
+    "promotion-must-have-conditions-or-coupon-code": "A Promotion must have either at least one condition or a coupon code set",
     "refund-order-item-already-refunded": "Cannot refund an OrderItem which has already been refunded",
     "refund-order-lines-invalid-order-state": "Cannot refund an Order in the \"{ state }\" state",
     "refund-order-lines-nothing-to-refund": "Nothing to refund",

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

@@ -156,7 +156,7 @@ describe('OrderCalculator', () => {
         });
 
         const fixedPriceOrderAction = new PromotionOrderAction({
-            code: 'fixed_price_item_action',
+            code: 'fixed_price_order_action',
             description: [{ languageCode: LanguageCode.en, value: '' }],
             args: {},
             execute(order) {
@@ -164,6 +164,24 @@ describe('OrderCalculator', () => {
             },
         });
 
+        const percentageItemAction = new PromotionItemAction({
+            code: 'percentage_item_action',
+            description: [{ languageCode: LanguageCode.en, value: '' }],
+            args: { discount: { type: 'int' } },
+            async execute(orderItem, orderLine, args, { hasFacetValues }) {
+                return -orderLine.unitPrice * (args.discount / 100);
+            },
+        });
+
+        const percentageOrderAction = new PromotionOrderAction({
+            code: 'percentage_order_action',
+            description: [{ languageCode: LanguageCode.en, value: '' }],
+            args: { discount: { type: 'int' } },
+            execute(order, args) {
+                return -order.subTotal * (args.discount / 100);
+            },
+        });
+
         it('single line with single applicable promotion', async () => {
             const promotion = new Promotion({
                 id: 1,
@@ -220,6 +238,112 @@ describe('OrderCalculator', () => {
             expect(order.total).toBe(42);
         });
 
+        it('percentage order discount (price includes tax)', async () => {
+            const promotion = new Promotion({
+                id: 1,
+                name: '50% off order',
+                conditions: [{ code: alwaysTrueCondition.code, args: [] }],
+                promotionConditions: [alwaysTrueCondition],
+                actions: [
+                    {
+                        code: percentageOrderAction.code,
+                        args: [{ name: 'discount', type: 'int', value: '50' }],
+                    },
+                ],
+                promotionActions: [percentageOrderAction],
+            });
+
+            const ctx = createRequestContext(true);
+            const order = createOrder({
+                lines: [{ unitPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }],
+            });
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+            expect(order.subTotal).toBe(100);
+            expect(order.adjustments.length).toBe(1);
+            expect(order.adjustments[0].description).toBe('50% off order');
+            expect(order.total).toBe(50);
+        });
+
+        it('percentage order discount (price excludes tax)', async () => {
+            const promotion = new Promotion({
+                id: 1,
+                name: '50% off order',
+                conditions: [{ code: alwaysTrueCondition.code, args: [] }],
+                promotionConditions: [alwaysTrueCondition],
+                actions: [
+                    {
+                        code: percentageOrderAction.code,
+                        args: [{ name: 'discount', type: 'int', value: '50' }],
+                    },
+                ],
+                promotionActions: [percentageOrderAction],
+            });
+
+            const ctx = createRequestContext(false);
+            const order = createOrder({
+                lines: [{ unitPrice: 83, taxCategory: taxCategoryStandard, quantity: 1 }],
+            });
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+            expect(order.subTotal).toBe(100);
+            expect(order.adjustments.length).toBe(1);
+            expect(order.adjustments[0].description).toBe('50% off order');
+            expect(order.total).toBe(50);
+        });
+
+        it('percentage items discount (price includes tax)', async () => {
+            const promotion = new Promotion({
+                id: 1,
+                name: '50% off each item',
+                conditions: [{ code: alwaysTrueCondition.code, args: [] }],
+                promotionConditions: [alwaysTrueCondition],
+                actions: [
+                    {
+                        code: percentageItemAction.code,
+                        args: [{ name: 'discount', type: 'int', value: '50' }],
+                    },
+                ],
+                promotionActions: [percentageItemAction],
+            });
+
+            const ctx = createRequestContext(true);
+            const order = createOrder({
+                lines: [{ unitPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }],
+            });
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+            expect(order.subTotal).toBe(50);
+            expect(order.lines[0].adjustments.length).toBe(1);
+            expect(order.lines[0].adjustments[0].description).toBe('50% off each item');
+            expect(order.total).toBe(50);
+        });
+
+        it('percentage items discount (price excludes tax)', async () => {
+            const promotion = new Promotion({
+                id: 1,
+                name: '50% off each item',
+                conditions: [{ code: alwaysTrueCondition.code, args: [] }],
+                promotionConditions: [alwaysTrueCondition],
+                actions: [
+                    {
+                        code: percentageItemAction.code,
+                        args: [{ name: 'discount', type: 'int', value: '50' }],
+                    },
+                ],
+                promotionActions: [percentageItemAction],
+            });
+
+            const ctx = createRequestContext(false);
+            const order = createOrder({
+                lines: [{ unitPrice: 83, taxCategory: taxCategoryStandard, quantity: 1 }],
+            });
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+            expect(order.subTotal).toBe(50);
+            expect(order.total).toBe(50);
+        });
+
         it('interaction between promotions', async () => {
             const orderQuantityCondition = new PromotionCondition({
                 args: { minimum: { type: 'int' } },

+ 3 - 0
packages/core/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -9,6 +9,7 @@ import { Order } from '../../../entity/order/order.entity';
 import { EventBus } from '../../../event-bus/event-bus';
 import { OrderStateTransitionEvent } from '../../../event-bus/events/order-state-transition-event';
 import { HistoryService } from '../../services/history.service';
+import { PromotionService } from '../../services/promotion.service';
 import { StockMovementService } from '../../services/stock-movement.service';
 
 import { OrderState, orderStateTransitions, OrderTransitionData } from './order-state';
@@ -22,6 +23,7 @@ export class OrderStateMachine {
         private configService: ConfigService,
         private stockMovementService: StockMovementService,
         private historyService: HistoryService,
+        private promotionService: PromotionService,
         private eventBus: EventBus,
     ) {
         this.config = this.initConfig();
@@ -68,6 +70,7 @@ export class OrderStateMachine {
             data.order.active = false;
             data.order.orderPlacedAt = new Date();
             await this.stockMovementService.createSalesForOrder(data.order);
+            await this.promotionService.addPromotionsToOrder(data.order);
         }
         if (toState === 'Cancelled') {
             data.order.active = false;

+ 45 - 2
packages/core/src/service/services/order.service.ts

@@ -55,6 +55,7 @@ import { CustomerService } from './customer.service';
 import { HistoryService } from './history.service';
 import { PaymentMethodService } from './payment-method.service';
 import { ProductVariantService } from './product-variant.service';
+import { PromotionService } from './promotion.service';
 import { StockMovementService } from './stock-movement.service';
 
 export class OrderService {
@@ -74,6 +75,7 @@ export class OrderService {
         private stockMovementService: StockMovementService,
         private refundStateMachine: RefundStateMachine,
         private historyService: HistoryService,
+        private promotionService: PromotionService,
     ) {}
 
     findAll(ctx: RequestContext, options?: ListQueryOptions<Order>): Promise<PaginatedList<Order>> {
@@ -191,6 +193,7 @@ export class OrderService {
             code: generatePublicId(),
             state: this.orderStateMachine.getInitialState(),
             lines: [],
+            couponCodes: [],
             shippingAddress: {},
             billingAddress: {},
             pendingAdjustments: [],
@@ -291,6 +294,29 @@ export class OrderService {
         return updatedOrder;
     }
 
+    async applyCouponCode(ctx: RequestContext, orderId: ID, couponCode: string) {
+        const order = await this.getOrderOrThrow(ctx, orderId);
+        if (order.couponCodes.includes(couponCode)) {
+            return order;
+        }
+        await this.promotionService.validateCouponCode(couponCode, order.customer && order.customer.id);
+        order.couponCodes.push(couponCode);
+        return this.applyPriceAdjustments(ctx, order);
+    }
+
+    async removeCouponCode(ctx: RequestContext, orderId: ID, couponCode: string) {
+        const order = await this.getOrderOrThrow(ctx, orderId);
+        order.couponCodes = order.couponCodes.filter(cc => cc !== couponCode);
+        return this.applyPriceAdjustments(ctx, order);
+    }
+
+    async getOrderPromotions(orderId: ID): Promise<Promotion[]> {
+        const order = await getEntityOrThrow(this.connection, Order, orderId, {
+            relations: ['promotions'],
+        });
+        return order.promotions || [];
+    }
+
     getNextOrderStates(order: Order): OrderState[] {
         return this.orderStateMachine.getNextStates(order);
     }
@@ -600,7 +626,24 @@ export class OrderService {
             throw new IllegalOperationError(`error.order-already-has-customer`);
         }
         order.customer = customer;
-        return this.connection.getRepository(Order).save(order);
+        await this.connection.getRepository(Order).save(order);
+        // Check that any applied couponCodes are still valid now that
+        // we know the Customer.
+        if (order.couponCodes) {
+            let codesRemoved = false;
+            for (const couponCode of order.couponCodes.slice()) {
+                try {
+                    await this.promotionService.validateCouponCode(couponCode, customer.id);
+                } catch (err) {
+                    order.couponCodes = order.couponCodes.filter(c => c !== couponCode);
+                    codesRemoved = true;
+                }
+            }
+            if (codesRemoved) {
+                return this.applyPriceAdjustments(ctx, order);
+            }
+        }
+        return order;
     }
 
     async addNoteToOrder(ctx: RequestContext, input: AddNoteToOrderInput): Promise<Order> {
@@ -719,7 +762,7 @@ export class OrderService {
      */
     private async applyPriceAdjustments(ctx: RequestContext, order: Order): Promise<Order> {
         const promotions = await this.connection.getRepository(Promotion).find({
-            where: { enabled: true },
+            where: { enabled: true, deletedAt: null },
             order: { priorityScore: 'ASC' },
         });
         order = await this.orderCalculator.applyPriceAdjustments(ctx, order, promotions);

+ 85 - 14
packages/core/src/service/services/promotion.service.ts

@@ -1,6 +1,8 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
 import {
+    Adjustment,
+    AdjustmentType,
     ConfigurableOperation,
     ConfigurableOperationDefinition,
     ConfigurableOperationInput,
@@ -11,16 +13,24 @@ import {
 } from '@vendure/common/lib/generated-types';
 import { omit } from '@vendure/common/lib/omit';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
 import { configurableDefToOperation } from '../../common/configurable-operation';
-import { UserInputError } from '../../common/error/errors';
+import {
+    CouponCodeExpiredError,
+    CouponCodeInvalidError,
+    CouponCodeLimitError,
+    UserInputError,
+} from '../../common/error/errors';
+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 { PromotionCondition } from '../../config/promotion/promotion-condition';
+import { Order } from '../../entity/order/order.entity';
 import { Promotion } from '../../entity/promotion/promotion.entity';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
@@ -82,34 +92,38 @@ export class PromotionService {
     }
 
     async createPromotion(ctx: RequestContext, input: CreatePromotionInput): Promise<Promotion> {
-        const adjustmentSource = new Promotion({
+        const promotion = new Promotion({
             name: input.name,
             enabled: input.enabled,
+            couponCode: input.couponCode,
+            perCustomerUsageLimit: input.perCustomerUsageLimit,
+            startsAt: input.startsAt,
+            endsAt: input.endsAt,
             conditions: input.conditions.map(c => this.parseOperationArgs('condition', c)),
             actions: input.actions.map(a => this.parseOperationArgs('action', a)),
             priorityScore: this.calculatePriorityScore(input),
         });
-        this.channelService.assignToChannels(adjustmentSource, ctx);
-        const newAdjustmentSource = await this.connection.manager.save(adjustmentSource);
+        this.validatePromotionConditions(promotion);
+        this.channelService.assignToChannels(promotion, ctx);
+        const newPromotion = await this.connection.manager.save(promotion);
         await this.updatePromotions();
-        return assertFound(this.findOne(newAdjustmentSource.id));
+        return assertFound(this.findOne(newPromotion.id));
     }
 
     async updatePromotion(ctx: RequestContext, input: UpdatePromotionInput): Promise<Promotion> {
-        const adjustmentSource = await getEntityOrThrow(this.connection, Promotion, input.id);
-        const updatedAdjustmentSource = patchEntity(adjustmentSource, omit(input, ['conditions', 'actions']));
+        const promotion = await getEntityOrThrow(this.connection, Promotion, input.id);
+        const updatedPromotion = patchEntity(promotion, omit(input, ['conditions', 'actions']));
         if (input.conditions) {
-            updatedAdjustmentSource.conditions = input.conditions.map(c =>
-                this.parseOperationArgs('condition', c),
-            );
+            updatedPromotion.conditions = input.conditions.map(c => this.parseOperationArgs('condition', c));
         }
         if (input.actions) {
-            updatedAdjustmentSource.actions = input.actions.map(a => this.parseOperationArgs('action', a));
+            updatedPromotion.actions = input.actions.map(a => this.parseOperationArgs('action', a));
         }
-        (adjustmentSource.priorityScore = this.calculatePriorityScore(input)),
-            await this.connection.manager.save(updatedAdjustmentSource);
+        this.validatePromotionConditions(updatedPromotion);
+        (promotion.priorityScore = this.calculatePriorityScore(input)),
+            await this.connection.manager.save(updatedPromotion);
         await this.updatePromotions();
-        return assertFound(this.findOne(updatedAdjustmentSource.id));
+        return assertFound(this.findOne(updatedPromotion.id));
     }
 
     async softDeletePromotion(promotionId: ID): Promise<DeletionResponse> {
@@ -120,6 +134,54 @@ export class PromotionService {
         };
     }
 
+    async validateCouponCode(couponCode: string, customerId?: ID): Promise<boolean> {
+        const promotion = await this.connection.getRepository(Promotion).findOne({
+            where: {
+                couponCode,
+                enabled: true,
+                deletedAt: null,
+            },
+        });
+        if (!promotion) {
+            throw new CouponCodeInvalidError(couponCode);
+        }
+        if (promotion.endsAt && +promotion.endsAt < +new Date()) {
+            throw new CouponCodeExpiredError(couponCode);
+        }
+        if (customerId && promotion.perCustomerUsageLimit != null) {
+            const usageCount = await this.countPromotionUsagesForCustomer(promotion.id, customerId);
+            if (promotion.perCustomerUsageLimit <= usageCount) {
+                throw new CouponCodeLimitError(promotion.perCustomerUsageLimit);
+            }
+        }
+        return true;
+    }
+
+    async addPromotionsToOrder(order: Order): Promise<Order> {
+        const allAdjustments: Adjustment[] = [];
+        for (const line of order.lines) {
+            allAdjustments.push(...line.adjustments);
+        }
+        allAdjustments.push(...order.adjustments);
+        const allPromotionIds = allAdjustments
+            .filter(a => a.type === AdjustmentType.PROMOTION)
+            .map(a => AdjustmentSource.decodeSourceId(a.adjustmentSource).id);
+        const promotionIds = unique(allPromotionIds);
+        const promotions = await this.connection.getRepository(Promotion).findByIds(promotionIds);
+        order.promotions = promotions;
+        return this.connection.getRepository(Order).save(order);
+    }
+
+    private async countPromotionUsagesForCustomer(promotionId: ID, customerId: ID): Promise<number> {
+        const qb = this.connection
+            .getRepository(Order)
+            .createQueryBuilder('order')
+            .leftJoin('order.promotions', 'promotion')
+            .where('promotion.id = :promotionId', { promotionId })
+            .andWhere('order.customer = :customerId', { customerId });
+
+        return qb.getCount();
+    }
     /**
      * Converts the input values of the "create" and "update" mutations into the format expected by the AdjustmentSource entity.
      */
@@ -166,4 +228,13 @@ export class PromotionService {
             where: { enabled: true },
         });
     }
+
+    /**
+     * Ensure the Promotion has at least one condition or a couponCode specified.
+     */
+    private validatePromotionConditions(promotion: Promotion) {
+        if (promotion.conditions.length === 0 && !promotion.couponCode) {
+            throw new UserInputError('error.promotion-must-have-conditions-or-coupon-code');
+        }
+    }
 }

+ 1 - 11
packages/dev-server/dev-config.ts

@@ -36,17 +36,7 @@ export const devConfig: VendureConfig = {
     paymentOptions: {
         paymentMethodHandlers: [examplePaymentHandler],
     },
-    customFields: {
-        Product: [
-            { name: 'length', type: 'int', min: 0, max: 100 },
-            {
-                name: 'offerImageId',
-                label: [{ languageCode: LanguageCode.en, value: 'Offer image' }],
-                type: 'string',
-            },
-        ],
-        ProductVariant: [{ name: 'length', type: 'int', min: 0, max: 100 }],
-    },
+    customFields: {},
     logger: new DefaultLogger({ level: LogLevel.Info }),
     importExportOptions: {
         importAssetsDir: path.join(__dirname, 'import-assets'),

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
schema-admin.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
schema-shop.json


Неке датотеке нису приказане због велике количине промена