Browse Source

feat(core): Implement per-customer usage limits for Promotions

Relates to #174

BREAKING CHANGE: A new `promotions` relation has been added to the order table, and a `perCustomerUsageLimit` column to the promotion table. This will require a DB migration.
Michael Bromley 6 years ago
parent
commit
9d450693c3

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

@@ -1472,18 +1472,22 @@ 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']>;
     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'];
@@ -1858,6 +1862,7 @@ export type Promotion = Node & {
     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>;

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

@@ -526,6 +526,7 @@ export type CreatePromotionInput = {
   startsAt?: Maybe<Scalars['DateTime']>,
   endsAt?: Maybe<Scalars['DateTime']>,
   couponCode?: Maybe<Scalars['String']>,
+  perCustomerUsageLimit?: Maybe<Scalars['Int']>,
   conditions: Array<ConfigurableOperationInput>,
   actions: Array<ConfigurableOperationInput>,
 };
@@ -2160,18 +2161,22 @@ 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']>,
   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'],
@@ -2613,6 +2618,7 @@ export type Promotion = Node & {
   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>,
@@ -2625,6 +2631,7 @@ export type PromotionFilterParameter = {
   startsAt?: Maybe<DateOperators>,
   endsAt?: Maybe<DateOperators>,
   couponCode?: Maybe<StringOperators>,
+  perCustomerUsageLimit?: Maybe<NumberOperators>,
   name?: Maybe<StringOperators>,
   enabled?: Maybe<BooleanOperators>,
 };
@@ -2649,6 +2656,7 @@ export type PromotionSortParameter = {
   startsAt?: Maybe<SortOrder>,
   endsAt?: Maybe<SortOrder>,
   couponCode?: Maybe<SortOrder>,
+  perCustomerUsageLimit?: Maybe<SortOrder>,
   name?: Maybe<SortOrder>,
 };
 
@@ -3388,6 +3396,7 @@ export type UpdatePromotionInput = {
   startsAt?: Maybe<Scalars['DateTime']>,
   endsAt?: Maybe<Scalars['DateTime']>,
   couponCode?: Maybe<Scalars['String']>,
+  perCustomerUsageLimit?: Maybe<Scalars['Int']>,
   conditions?: Maybe<Array<ConfigurableOperationInput>>,
   actions?: Maybe<Array<ConfigurableOperationInput>>,
 };

+ 9 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -526,6 +526,7 @@ export type CreatePromotionInput = {
     startsAt?: Maybe<Scalars['DateTime']>;
     endsAt?: Maybe<Scalars['DateTime']>;
     couponCode?: Maybe<Scalars['String']>;
+    perCustomerUsageLimit?: Maybe<Scalars['Int']>;
     conditions: Array<ConfigurableOperationInput>;
     actions: Array<ConfigurableOperationInput>;
 };
@@ -2095,18 +2096,22 @@ 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']>;
     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'];
@@ -2546,6 +2551,7 @@ export type Promotion = Node & {
     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>;
@@ -2558,6 +2564,7 @@ export type PromotionFilterParameter = {
     startsAt?: Maybe<DateOperators>;
     endsAt?: Maybe<DateOperators>;
     couponCode?: Maybe<StringOperators>;
+    perCustomerUsageLimit?: Maybe<NumberOperators>;
     name?: Maybe<StringOperators>;
     enabled?: Maybe<BooleanOperators>;
 };
@@ -2582,6 +2589,7 @@ export type PromotionSortParameter = {
     startsAt?: Maybe<SortOrder>;
     endsAt?: Maybe<SortOrder>;
     couponCode?: Maybe<SortOrder>;
+    perCustomerUsageLimit?: Maybe<SortOrder>;
     name?: Maybe<SortOrder>;
 };
 
@@ -3287,6 +3295,7 @@ export type UpdatePromotionInput = {
     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 - 1
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1472,18 +1472,22 @@ 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']>;
     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'];
@@ -1858,6 +1862,7 @@ export type Promotion = Node & {
     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>;
@@ -2210,7 +2215,7 @@ export type Zone = Node & {
 };
 export type TestOrderFragmentFragment = { __typename?: 'Order' } & Pick<
     Order,
-    'id' | 'code' | 'state' | 'active' | 'total' | 'shipping'
+    'id' | 'code' | 'state' | 'active' | 'total' | 'couponCodes' | 'shipping'
 > & {
         adjustments: Array<
             { __typename?: 'Adjustment' } & Pick<

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

@@ -7,6 +7,7 @@ export const TEST_ORDER_FRAGMENT = gql`
         state
         active
         total
+        couponCodes
         adjustments {
             adjustmentSource
             amount

+ 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) {

+ 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' }],

+ 194 - 5
packages/core/e2e/shop-promotion.e2e-spec.ts

@@ -1,7 +1,6 @@
 /* tslint:disable:no-non-null-assertion */
 import gql from 'graphql-tag';
 import path from 'path';
-import { expand } from 'rxjs/operators';
 
 import {
     discountOnItemWithFacets,
@@ -20,18 +19,27 @@ import {
     AddItemToOrder,
     AdjustItemQuantity,
     ApplyCouponCode,
+    GetActiveOrder,
     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,
     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('Shop orders', () => {
     const adminClient = new TestAdminClient();
@@ -53,10 +61,17 @@ describe('Shop orders', () => {
     let products: GetPromoProducts.Items[];
 
     beforeAll(async () => {
-        const token = await server.init({
-            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-promotions.csv'),
-            customerCount: 2,
-        });
+        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();
 
@@ -139,11 +154,23 @@ describe('Shop orders', () => {
                 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,
@@ -337,6 +364,168 @@ describe('Shop orders', () => {
         });
     });
 
+    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';
+
+            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);
+            });
+
+            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: {

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

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

@@ -20,6 +20,7 @@ input CreatePromotionInput {
     startsAt: DateTime
     endsAt: DateTime
     couponCode: String
+    perCustomerUsageLimit: Int
     conditions: [ConfigurableOperationInput!]!
     actions: [ConfigurableOperationInput!]!
 }
@@ -31,6 +32,7 @@ input UpdatePromotionInput {
     startsAt: DateTime
     endsAt: DateTime
     couponCode: String
+    perCustomerUsageLimit: Int
     conditions: [ConfigurableOperationInput!]
     actions: [ConfigurableOperationInput!]
 }

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

@@ -5,6 +5,7 @@ type Promotion implements Node {
     startsAt: DateTime
     endsAt: DateTime
     couponCode: String
+    perCustomerUsageLimit: Int
     name: String!
     enabled: Boolean!
     conditions: [ConfigurableOperation!]!

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

@@ -234,3 +234,16 @@ export class CouponCodeExpiredError extends I18nError {
         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>;
 }

+ 6 - 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';
 
 /**
@@ -50,6 +51,10 @@ export class Order extends VendureEntity implements HasCustomFields {
     @Column('simple-array')
     couponCodes: string[];
 
+    @ManyToMany(type => Promotion)
+    @JoinTable()
+    promotions: Promotion[];
+
     @Column('simple-json') pendingAdjustments: Adjustment[];
 
     @Column('simple-json') shippingAddress: OrderAddress;

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

@@ -71,6 +71,9 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
     @Column({ nullable: true, default: null })
     couponCode: string;
 
+    @Column({ nullable: true, default: null })
+    perCustomerUsageLimit: number;
+
     @Column() name: string;
 
     @Column() enabled: boolean;

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

@@ -15,6 +15,7 @@
     "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",

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

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

@@ -295,8 +295,11 @@ export class OrderService {
     }
 
     async applyCouponCode(ctx: RequestContext, orderId: ID, couponCode: string) {
-        await this.promotionService.validateCouponCode(couponCode);
         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);
     }
@@ -616,7 +619,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> {

+ 44 - 2
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 { CouponCodeExpiredError, CouponCodeInvalidError, 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';
@@ -86,6 +96,7 @@ export class PromotionService {
             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)),
@@ -123,7 +134,7 @@ export class PromotionService {
         };
     }
 
-    async validateCouponCode(couponCode: string): Promise<boolean> {
+    async validateCouponCode(couponCode: string, customerId?: ID): Promise<boolean> {
         const promotion = await this.connection.getRepository(Promotion).findOne({
             where: {
                 couponCode,
@@ -137,9 +148,40 @@ export class PromotionService {
         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.
      */

File diff suppressed because it is too large
+ 0 - 0
schema-admin.json


File diff suppressed because it is too large
+ 0 - 0
schema-shop.json


Some files were not shown because too many files changed in this diff