Browse Source

feat(core): Implement Promotion date range & coupon code checks

Relates to #174

BREAKING CHANGE: Removes `atLeastNOfProduct` from defaultPromotionConditions and `itemPercentageDiscount` & `buy1Get1Free` from defaultPromotionActions. They are either not useful or need to be re-implemented in a way that works correctly.
Michael Bromley 6 years ago
parent
commit
f6eb343f7b

+ 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          ,               ,

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

@@ -2103,6 +2103,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'];
@@ -4338,6 +4339,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;
 };
@@ -4627,14 +4636,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;
 };
@@ -4908,6 +4909,33 @@ export type GetCustomerIdsQuery = { __typename?: 'Query' } & {
     };
 };
 
+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 UpdateStockMutationVariables = {
     input: Array<UpdateProductVariantInput>;
 };
@@ -5636,6 +5664,12 @@ 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;
@@ -5833,12 +5867,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;
@@ -6030,6 +6058,27 @@ export namespace GetCustomerIds {
     export type Items = NonNullable<GetCustomerIdsQuery['customers']['items'][0]>;
 }
 
+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 UpdateStock {
     export type Variables = UpdateStockMutationVariables;
     export type Mutation = UpdateStockMutation;

+ 59 - 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'];
 };
@@ -1468,6 +1480,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'];
@@ -2197,8 +2210,14 @@ export type Zone = Node & {
 };
 export type TestOrderFragmentFragment = { __typename?: 'Order' } & Pick<
     Order,
-    'id' | 'code' | 'state' | 'active' | 'shipping'
+    'id' | 'code' | 'state' | 'active' | 'total' | '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'>;
@@ -2217,12 +2236,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'
+                    >
+                >;
             }
     >;
 };
@@ -2529,8 +2554,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']>;
@@ -2545,6 +2587,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 {
@@ -2732,3 +2777,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}
+`;

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

@@ -6,6 +6,13 @@ export const TEST_ORDER_FRAGMENT = gql`
         code
         state
         active
+        total
+        adjustments {
+            adjustmentSource
+            amount
+            description
+            type
+        }
         lines {
             id
             quantity
@@ -32,6 +39,7 @@ export const ADD_ITEM_TO_ORDER = gql`
             code
             state
             active
+            total
             lines {
                 id
                 quantity
@@ -39,6 +47,12 @@ export const ADD_ITEM_TO_ORDER = gql`
                     id
                 }
             }
+            adjustments {
+                adjustmentSource
+                amount
+                description
+                type
+            }
         }
     }
 `;
@@ -315,3 +329,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}
+`;

+ 39 - 9
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';
@@ -99,6 +100,31 @@ 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,
@@ -124,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,
@@ -250,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) {

+ 417 - 0
packages/core/e2e/shop-promotion.e2e-spec.ts

@@ -0,0 +1,417 @@
+/* tslint:disable:no-non-null-assertion */
+import gql from 'graphql-tag';
+import path from 'path';
+import { expand } from 'rxjs/operators';
+
+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,
+    RemoveCouponCode,
+} 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,
+    REMOVE_COUPON_CODE,
+} from './graphql/shop-definitions';
+import { TestAdminClient, TestShopClient } from './test-client';
+import { TestServer } from './test-server';
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+
+describe('Shop 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,
+        });
+        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!.adjustments.length).toBe(1);
+            expect(applyCouponCode!.adjustments[0].description).toBe('Free with test coupon');
+            expect(applyCouponCode!.total).toBe(0);
+        });
+
+        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!.adjustments.length).toBe(1);
+            //  expect(applyCouponCode!.adjustments[0].description).toBe('50% off sale items');
+            expect(applyCouponCode!.total).toBe(1920);
+
+            await deletePromotion(promotion.id);
+        });
+    });
+
+    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
+                    }
+                }
+            }
+        }
+    }
+`;

+ 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 - 15
packages/core/src/config/promotion/default-promotion-conditions.ts

@@ -21,20 +21,6 @@ export const minimumOrderAmount = new PromotionCondition({
     priorityValue: 10,
 });
 
-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: [
@@ -55,4 +41,4 @@ export const atLeastNWithFacets = new PromotionCondition({
     },
 });
 
-export const defaultPromotionConditions = [minimumOrderAmount, atLeastNOfProduct, atLeastNWithFacets];
+export const defaultPromotionConditions = [minimumOrderAmount, atLeastNWithFacets];

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

@@ -128,6 +128,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))) {