소스 검색

feat(core): Add maximum coupon usage (#2331)

Closes #2330
Co-authored-by: Karics <karoly.pakozdi.dev@gmail.com>
PKarics 2 년 전
부모
커밋
bdd27201bd
23개의 변경된 파일292개의 추가작업 그리고 10개의 파일을 삭제
  1. 10 5
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 1 0
      packages/admin-ui/src/lib/core/src/data/definitions/promotion-definitions.ts
  3. 2 0
      packages/admin-ui/src/lib/core/src/data/providers/promotion-data.service.ts
  4. 15 0
      packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.html
  5. 3 0
      packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.ts
  6. 9 0
      packages/admin-ui/src/lib/marketing/src/components/promotion-list/promotion-list.component.html
  7. 8 1
      packages/admin-ui/src/lib/marketing/src/components/promotion-list/promotion-list.component.ts
  8. 5 0
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  9. 1 0
      packages/common/src/generated-shop-types.ts
  10. 5 0
      packages/common/src/generated-types.ts
  11. 5 0
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  12. 1 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  13. 191 4
      packages/core/e2e/order-promotion.e2e-spec.ts
  14. 2 0
      packages/core/src/api/schema/admin-api/promotion.api.graphql
  15. 1 0
      packages/core/src/api/schema/common/promotion.type.graphql
  16. 3 0
      packages/core/src/entity/promotion/promotion.entity.ts
  17. 18 0
      packages/core/src/service/services/promotion.service.ts
  18. 5 0
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  19. 5 0
      packages/payments-plugin/e2e/graphql/generated-admin-types.ts
  20. 1 0
      packages/payments-plugin/e2e/graphql/generated-shop-types.ts
  21. 1 0
      packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts
  22. 0 0
      schema-admin.json
  23. 0 0
      schema-shop.json

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 10 - 5
packages/admin-ui/src/lib/core/src/common/generated-types.ts


+ 1 - 0
packages/admin-ui/src/lib/core/src/data/definitions/promotion-definitions.ts

@@ -16,6 +16,7 @@ export const PROMOTION_FRAGMENT = gql`
         enabled
         couponCode
         perCustomerUsageLimit
+        usageLimit
         startsAt
         endsAt
         conditions {

+ 2 - 0
packages/admin-ui/src/lib/core/src/data/providers/promotion-data.service.ts

@@ -30,6 +30,7 @@ export class PromotionDataService {
                 'startsAt',
                 'endsAt',
                 'perCustomerUsageLimit',
+                'usageLimit',
                 'enabled',
                 'translations',
                 'customFields',
@@ -50,6 +51,7 @@ export class PromotionDataService {
                 'startsAt',
                 'endsAt',
                 'perCustomerUsageLimit',
+                'usageLimit',
                 'enabled',
                 'translations',
                 'customFields',

+ 15 - 0
packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.html

@@ -81,6 +81,7 @@
                     </vdr-form-field>
                     <vdr-form-field
                         [label]="'marketing.per-customer-limit' | translate"
+                        [tooltip]="'marketing.per-customer-limit-tooltip' | translate"
                         for="perCustomerUsageLimit"
                     >
                         <input
@@ -92,6 +93,20 @@
                             formControlName="perCustomerUsageLimit"
                         />
                     </vdr-form-field>
+                    <vdr-form-field
+                        [label]="'marketing.usage-limit' | translate"
+                        [tooltip]="'marketing.usage-limit-tooltip' | translate"
+                        for="usageLimit"
+                    >
+                        <input
+                            id="usageLimit"
+                            [readonly]="!('UpdatePromotion' | hasPermission)"
+                            type="number"
+                            min="1"
+                            max="9999999"
+                            formControlName="usageLimit"
+                        />
+                    </vdr-form-field>
                 </div>
             </vdr-card>
             <vdr-card

+ 3 - 0
packages/admin-ui/src/lib/marketing/src/components/promotion-detail/promotion-detail.component.ts

@@ -50,6 +50,7 @@ export class PromotionDetailComponent
         enabled: true,
         couponCode: null as string | null,
         perCustomerUsageLimit: null as number | null,
+        usageLimit: null as number | null,
         startsAt: null,
         endsAt: null,
         conditions: this.formBuilder.array([]),
@@ -152,6 +153,7 @@ export class PromotionDetailComponent
                 description: '',
                 couponCode: null,
                 perCustomerUsageLimit: null,
+                usageLimit: null,
                 enabled: false,
                 conditions: [],
                 actions: [],
@@ -256,6 +258,7 @@ export class PromotionDetailComponent
             enabled: entity.enabled,
             couponCode: entity.couponCode,
             perCustomerUsageLimit: entity.perCustomerUsageLimit,
+            usageLimit: entity.usageLimit,
             startsAt: entity.startsAt,
             endsAt: entity.endsAt,
         });

+ 9 - 0
packages/admin-ui/src/lib/marketing/src/components/promotion-list/promotion-list.component.html

@@ -99,5 +99,14 @@
             {{ promotion.perCustomerUsageLimit }}
         </ng-template>
     </vdr-dt2-column>
+    <vdr-dt2-column
+        [heading]="'marketing.per-customer-limit' | translate"
+        [sort]="sorts.get('usageLimit')"
+        [hiddenByDefault]="true"
+    >
+        <ng-template let-promotion="item">
+            {{ promotion.usageLimit }}
+        </ng-template>
+    </vdr-dt2-column>
     <vdr-dt2-custom-field-column *ngFor="let customField of customFields" [customField]="customField" [sorts]="sorts" />
 </vdr-data-table-2>

+ 8 - 1
packages/admin-ui/src/lib/marketing/src/components/promotion-list/promotion-list.component.ts

@@ -73,11 +73,17 @@ export class PromotionListComponent
                 filterField: 'description',
             },
             {
-                name: 'usageLimit',
+                name: 'perCustomerUsageLimit',
                 type: { kind: 'number' },
                 label: _('marketing.per-customer-limit'),
                 filterField: 'perCustomerUsageLimit',
             },
+            {
+                name: 'usageLimit',
+                type: { kind: 'number' },
+                label: _('marketing.usage-limit'),
+                filterField: 'usageLimit',
+            },
         ])
         .addCustomFieldFilters(this.customFields)
         .connectToRoute(this.route);
@@ -92,6 +98,7 @@ export class PromotionListComponent
             { name: 'name' },
             { name: 'couponCode' },
             { name: 'perCustomerUsageLimit' },
+            { name: 'usageLimit' },
         ])
         .addCustomFieldSorts(this.customFields)
         .connectToRoute(this.route);

+ 5 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -845,6 +845,7 @@ export type CreatePromotionInput = {
   perCustomerUsageLimit?: InputMaybe<Scalars['Int']>;
   startsAt?: InputMaybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslationInput>;
+  usageLimit?: InputMaybe<Scalars['Int']>;
 };
 
 export type CreatePromotionResult = MissingConditionsError | Promotion;
@@ -4591,6 +4592,7 @@ export type Promotion = Node & {
   startsAt?: Maybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslation>;
   updatedAt: Scalars['DateTime'];
+  usageLimit?: Maybe<Scalars['Int']>;
 };
 
 export type PromotionFilterParameter = {
@@ -4604,6 +4606,7 @@ export type PromotionFilterParameter = {
   perCustomerUsageLimit?: InputMaybe<NumberOperators>;
   startsAt?: InputMaybe<DateOperators>;
   updatedAt?: InputMaybe<DateOperators>;
+  usageLimit?: InputMaybe<NumberOperators>;
 };
 
 export type PromotionList = PaginatedList & {
@@ -4634,6 +4637,7 @@ export type PromotionSortParameter = {
   perCustomerUsageLimit?: InputMaybe<SortOrder>;
   startsAt?: InputMaybe<SortOrder>;
   updatedAt?: InputMaybe<SortOrder>;
+  usageLimit?: InputMaybe<SortOrder>;
 };
 
 export type PromotionTranslation = {
@@ -6114,6 +6118,7 @@ export type UpdatePromotionInput = {
   perCustomerUsageLimit?: InputMaybe<Scalars['Int']>;
   startsAt?: InputMaybe<Scalars['DateTime']>;
   translations?: InputMaybe<Array<PromotionTranslationInput>>;
+  usageLimit?: InputMaybe<Scalars['Int']>;
 };
 
 export type UpdatePromotionResult = MissingConditionsError | Promotion;

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

@@ -2740,6 +2740,7 @@ export type Promotion = Node & {
   startsAt?: Maybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslation>;
   updatedAt: Scalars['DateTime'];
+  usageLimit?: Maybe<Scalars['Int']>;
 };
 
 export type PromotionList = PaginatedList & {

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

@@ -876,6 +876,7 @@ export type CreatePromotionInput = {
   perCustomerUsageLimit?: InputMaybe<Scalars['Int']>;
   startsAt?: InputMaybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslationInput>;
+  usageLimit?: InputMaybe<Scalars['Int']>;
 };
 
 export type CreatePromotionResult = MissingConditionsError | Promotion;
@@ -4713,6 +4714,7 @@ export type Promotion = Node & {
   startsAt?: Maybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslation>;
   updatedAt: Scalars['DateTime'];
+  usageLimit?: Maybe<Scalars['Int']>;
 };
 
 export type PromotionFilterParameter = {
@@ -4726,6 +4728,7 @@ export type PromotionFilterParameter = {
   perCustomerUsageLimit?: InputMaybe<NumberOperators>;
   startsAt?: InputMaybe<DateOperators>;
   updatedAt?: InputMaybe<DateOperators>;
+  usageLimit?: InputMaybe<NumberOperators>;
 };
 
 export type PromotionList = PaginatedList & {
@@ -4757,6 +4760,7 @@ export type PromotionSortParameter = {
   perCustomerUsageLimit?: InputMaybe<SortOrder>;
   startsAt?: InputMaybe<SortOrder>;
   updatedAt?: InputMaybe<SortOrder>;
+  usageLimit?: InputMaybe<SortOrder>;
 };
 
 export type PromotionTranslation = {
@@ -6287,6 +6291,7 @@ export type UpdatePromotionInput = {
   perCustomerUsageLimit?: InputMaybe<Scalars['Int']>;
   startsAt?: InputMaybe<Scalars['DateTime']>;
   translations?: InputMaybe<Array<PromotionTranslationInput>>;
+  usageLimit?: InputMaybe<Scalars['Int']>;
 };
 
 export type UpdatePromotionResult = MissingConditionsError | Promotion;

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

@@ -845,6 +845,7 @@ export type CreatePromotionInput = {
   perCustomerUsageLimit?: InputMaybe<Scalars['Int']>;
   startsAt?: InputMaybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslationInput>;
+  usageLimit?: InputMaybe<Scalars['Int']>;
 };
 
 export type CreatePromotionResult = MissingConditionsError | Promotion;
@@ -4591,6 +4592,7 @@ export type Promotion = Node & {
   startsAt?: Maybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslation>;
   updatedAt: Scalars['DateTime'];
+  usageLimit?: Maybe<Scalars['Int']>;
 };
 
 export type PromotionFilterParameter = {
@@ -4604,6 +4606,7 @@ export type PromotionFilterParameter = {
   perCustomerUsageLimit?: InputMaybe<NumberOperators>;
   startsAt?: InputMaybe<DateOperators>;
   updatedAt?: InputMaybe<DateOperators>;
+  usageLimit?: InputMaybe<NumberOperators>;
 };
 
 export type PromotionList = PaginatedList & {
@@ -4634,6 +4637,7 @@ export type PromotionSortParameter = {
   perCustomerUsageLimit?: InputMaybe<SortOrder>;
   startsAt?: InputMaybe<SortOrder>;
   updatedAt?: InputMaybe<SortOrder>;
+  usageLimit?: InputMaybe<SortOrder>;
 };
 
 export type PromotionTranslation = {
@@ -6114,6 +6118,7 @@ export type UpdatePromotionInput = {
   perCustomerUsageLimit?: InputMaybe<Scalars['Int']>;
   startsAt?: InputMaybe<Scalars['DateTime']>;
   translations?: InputMaybe<Array<PromotionTranslationInput>>;
+  usageLimit?: InputMaybe<Scalars['Int']>;
 };
 
 export type UpdatePromotionResult = MissingConditionsError | Promotion;

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

@@ -2649,6 +2649,7 @@ export type Promotion = Node & {
   startsAt?: Maybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslation>;
   updatedAt: Scalars['DateTime'];
+  usageLimit?: Maybe<Scalars['Int']>;
 };
 
 export type PromotionList = PaginatedList & {

+ 191 - 4
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -1550,8 +1550,8 @@ describe('Promotions applied to Orders', () => {
                 >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
                 orderResultGuard.assertSuccess(applyCouponCode);
 
-                expect(applyCouponCode!.totalWithTax).toBe(0);
-                expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
+                expect(applyCouponCode.totalWithTax).toBe(0);
+                expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
 
                 await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(
                     SET_CUSTOMER,
@@ -1565,8 +1565,8 @@ describe('Promotions applied to Orders', () => {
                 );
 
                 const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
-                expect(activeOrder!.couponCodes).toEqual([TEST_COUPON_CODE]);
-                expect(applyCouponCode!.totalWithTax).toBe(0);
+                expect(activeOrder.couponCodes).toEqual([TEST_COUPON_CODE]);
+                expect(applyCouponCode.totalWithTax).toBe(0);
             });
         });
 
@@ -1662,6 +1662,193 @@ describe('Promotions applied to Orders', () => {
         });
     });
 
+    describe.only('usage limit', () => {
+        const TEST_COUPON_CODE = 'TESTCOUPON';
+        const orderGuard: ErrorResultGuard<CodegenShop.TestOrderWithPaymentsFragment> =
+            createErrorResultGuard(input => !!input.lines);
+        let promoWithUsageLimit: Codegen.PromotionFragment;
+
+        async function createNewActiveOrder() {
+            const { addItemToOrder } = await shopClient.query<
+                CodegenShop.AddItemToOrderMutation,
+                CodegenShop.AddItemToOrderMutationVariables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: getVariantBySlug('item-5000').id,
+                quantity: 1,
+            });
+            return addItemToOrder;
+        }
+
+        describe('guest customer', () => {
+            const GUEST_EMAIL_ADDRESS = 'guest@test.com';
+            let orderCode: string;
+
+            beforeAll(async () => {
+                promoWithUsageLimit = await createPromotion({
+                    enabled: true,
+                    name: 'Free with test coupon',
+                    couponCode: TEST_COUPON_CODE,
+                    usageLimit: 1,
+                    conditions: [],
+                    actions: [freeOrderAction],
+                });
+            });
+
+            afterAll(async () => {
+                await deletePromotion(promoWithUsageLimit.id);
+            });
+
+            function addGuestCustomerToOrder() {
+                return shopClient.query<
+                    CodegenShop.SetCustomerForOrderMutation,
+                    CodegenShop.SetCustomerForOrderMutationVariables
+                >(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<
+                    CodegenShop.ApplyCouponCodeMutation,
+                    CodegenShop.ApplyCouponCodeMutationVariables
+                >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
+                orderResultGuard.assertSuccess(applyCouponCode);
+
+                expect(applyCouponCode.totalWithTax).toBe(0);
+                expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
+
+                await proceedToArrangingPayment(shopClient);
+                const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+                orderGuard.assertSuccess(order);
+
+                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<
+                    CodegenShop.GetOrderPromotionsByCodeQuery,
+                    CodegenShop.GetOrderPromotionsByCodeQueryVariables
+                >(GET_ORDER_PROMOTIONS_BY_CODE, {
+                    code: orderCode,
+                });
+                expect(orderByCode!.promotions.map(pick(['name']))).toEqual([
+                    { name: 'Free with test coupon' },
+                ]);
+            });
+
+            it('returns error result when usage exceeds limit', async () => {
+                await shopClient.asAnonymousUser();
+                await createNewActiveOrder();
+                await addGuestCustomerToOrder();
+
+                const { applyCouponCode } = await shopClient.query<
+                    CodegenShop.ApplyCouponCodeMutation,
+                    CodegenShop.ApplyCouponCodeMutationVariables
+                >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
+                orderResultGuard.assertErrorResult(applyCouponCode);
+
+                expect(applyCouponCode.message).toEqual(
+                    'Coupon code cannot be used more than once per customer',
+                );
+            });
+        });
+
+        describe('signed-in customer', () => {
+            beforeAll(async () => {
+                promoWithUsageLimit = await createPromotion({
+                    enabled: true,
+                    name: 'Free with test coupon',
+                    couponCode: TEST_COUPON_CODE,
+                    usageLimit: 1,
+                    conditions: [],
+                    actions: [freeOrderAction],
+                });
+            });
+
+            afterAll(async () => {
+                await deletePromotion(promoWithUsageLimit.id);
+            });
+
+            function logInAsRegisteredCustomer() {
+                return shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+            }
+
+            let orderId: string;
+
+            it('allows initial usage', async () => {
+                await logInAsRegisteredCustomer();
+                await createNewActiveOrder();
+                const { applyCouponCode } = await shopClient.query<
+                    CodegenShop.ApplyCouponCodeMutation,
+                    CodegenShop.ApplyCouponCodeMutationVariables
+                >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
+                orderResultGuard.assertSuccess(applyCouponCode);
+
+                expect(applyCouponCode.totalWithTax).toBe(0);
+                expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
+
+                await proceedToArrangingPayment(shopClient);
+                const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+                orderGuard.assertSuccess(order);
+                orderId = order.id;
+
+                expect(order.state).toBe('PaymentSettled');
+                expect(order.active).toBe(false);
+            });
+
+            it('returns error result when usage exceeds limit', async () => {
+                await logInAsRegisteredCustomer();
+                await createNewActiveOrder();
+                const { applyCouponCode } = await shopClient.query<
+                    CodegenShop.ApplyCouponCodeMutation,
+                    CodegenShop.ApplyCouponCodeMutationVariables
+                >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
+                orderResultGuard.assertErrorResult(applyCouponCode);
+                expect(applyCouponCode.message).toEqual(
+                    'Coupon code cannot be used more than once per customer',
+                );
+                expect(applyCouponCode.errorCode).toBe(ErrorCode.COUPON_CODE_LIMIT_ERROR);
+            });
+
+            // https://github.com/vendure-ecommerce/vendure/issues/1466
+            it('cancelled orders do not count against usage limit', async () => {
+                const { cancelOrder } = await adminClient.query<
+                    Codegen.CancelOrderMutation,
+                    Codegen.CancelOrderMutationVariables
+                >(CANCEL_ORDER, {
+                    input: {
+                        orderId,
+                        cancelShipping: true,
+                        reason: 'request',
+                    },
+                });
+                orderResultGuard.assertSuccess(cancelOrder);
+                expect(cancelOrder.state).toBe('Cancelled');
+
+                await logInAsRegisteredCustomer();
+                await createNewActiveOrder();
+                const { applyCouponCode } = await shopClient.query<
+                    CodegenShop.ApplyCouponCodeMutation,
+                    CodegenShop.ApplyCouponCodeMutationVariables
+                >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
+                orderResultGuard.assertSuccess(applyCouponCode);
+
+                expect(applyCouponCode.totalWithTax).toBe(0);
+                expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
+            });
+        });
+    });
+
     // https://github.com/vendure-ecommerce/vendure/issues/710
     it('removes order-level discount made invalid by removing OrderLine', async () => {
         const promotion = await createPromotion({

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

@@ -31,6 +31,7 @@ input CreatePromotionInput {
     endsAt: DateTime
     couponCode: String
     perCustomerUsageLimit: Int
+    usageLimit: Int
     conditions: [ConfigurableOperationInput!]!
     actions: [ConfigurableOperationInput!]!
     translations: [PromotionTranslationInput!]!
@@ -43,6 +44,7 @@ input UpdatePromotionInput {
     endsAt: DateTime
     couponCode: String
     perCustomerUsageLimit: Int
+    usageLimit: Int
     conditions: [ConfigurableOperationInput!]
     actions: [ConfigurableOperationInput!]
     translations: [PromotionTranslationInput!]

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

@@ -6,6 +6,7 @@ type Promotion implements Node {
     endsAt: DateTime
     couponCode: String
     perCustomerUsageLimit: Int
+    usageLimit: Int
     name: String!
     description: String!
     enabled: Boolean!

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

@@ -95,6 +95,9 @@ export class Promotion
     @Column({ nullable: true })
     perCustomerUsageLimit: number;
 
+    @Column({ nullable: true })
+    usageLimit: number;
+
     name: LocaleString;
 
     description: LocaleString;

+ 18 - 0
packages/core/src/service/services/promotion.service.ts

@@ -270,6 +270,12 @@ export class PromotionService {
                 return new CouponCodeLimitError({ couponCode, limit: promotion.perCustomerUsageLimit });
             }
         }
+        if (promotion.usageLimit !== null) {
+            const usageCount = await this.countPromotionUsages(ctx, promotion.id);
+            if (promotion.usageLimit <= usageCount) {
+                return new CouponCodeLimitError({ couponCode, limit: promotion.usageLimit });
+            }
+        }
         return promotion;
     }
 
@@ -348,6 +354,18 @@ export class PromotionService {
         return qb.getCount();
     }
 
+    private async countPromotionUsages(ctx: RequestContext, promotionId: ID): Promise<number> {
+        const qb = this.connection
+            .getRepository(ctx, Order)
+            .createQueryBuilder('order')
+            .leftJoin('order.promotions', 'promotion')
+            .where('promotion.id = :promotionId', { promotionId })
+            .andWhere('order.state != :state', { state: 'Cancelled' as OrderState })
+            .andWhere('order.active = :active', { active: false });
+
+        return qb.getCount();
+    }
+
     private calculatePriorityScore(input: CreatePromotionInput | UpdatePromotionInput): number {
         const conditions = input.conditions
             ? input.conditions.map(c => this.configArgService.getByCode('PromotionCondition', c.code))

+ 5 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -845,6 +845,7 @@ export type CreatePromotionInput = {
   perCustomerUsageLimit?: InputMaybe<Scalars['Int']>;
   startsAt?: InputMaybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslationInput>;
+  usageLimit?: InputMaybe<Scalars['Int']>;
 };
 
 export type CreatePromotionResult = MissingConditionsError | Promotion;
@@ -4591,6 +4592,7 @@ export type Promotion = Node & {
   startsAt?: Maybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslation>;
   updatedAt: Scalars['DateTime'];
+  usageLimit?: Maybe<Scalars['Int']>;
 };
 
 export type PromotionFilterParameter = {
@@ -4604,6 +4606,7 @@ export type PromotionFilterParameter = {
   perCustomerUsageLimit?: InputMaybe<NumberOperators>;
   startsAt?: InputMaybe<DateOperators>;
   updatedAt?: InputMaybe<DateOperators>;
+  usageLimit?: InputMaybe<NumberOperators>;
 };
 
 export type PromotionList = PaginatedList & {
@@ -4634,6 +4637,7 @@ export type PromotionSortParameter = {
   perCustomerUsageLimit?: InputMaybe<SortOrder>;
   startsAt?: InputMaybe<SortOrder>;
   updatedAt?: InputMaybe<SortOrder>;
+  usageLimit?: InputMaybe<SortOrder>;
 };
 
 export type PromotionTranslation = {
@@ -6114,6 +6118,7 @@ export type UpdatePromotionInput = {
   perCustomerUsageLimit?: InputMaybe<Scalars['Int']>;
   startsAt?: InputMaybe<Scalars['DateTime']>;
   translations?: InputMaybe<Array<PromotionTranslationInput>>;
+  usageLimit?: InputMaybe<Scalars['Int']>;
 };
 
 export type UpdatePromotionResult = MissingConditionsError | Promotion;

+ 5 - 0
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -845,6 +845,7 @@ export type CreatePromotionInput = {
   perCustomerUsageLimit?: InputMaybe<Scalars['Int']>;
   startsAt?: InputMaybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslationInput>;
+  usageLimit?: InputMaybe<Scalars['Int']>;
 };
 
 export type CreatePromotionResult = MissingConditionsError | Promotion;
@@ -4591,6 +4592,7 @@ export type Promotion = Node & {
   startsAt?: Maybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslation>;
   updatedAt: Scalars['DateTime'];
+  usageLimit?: Maybe<Scalars['Int']>;
 };
 
 export type PromotionFilterParameter = {
@@ -4604,6 +4606,7 @@ export type PromotionFilterParameter = {
   perCustomerUsageLimit?: InputMaybe<NumberOperators>;
   startsAt?: InputMaybe<DateOperators>;
   updatedAt?: InputMaybe<DateOperators>;
+  usageLimit?: InputMaybe<NumberOperators>;
 };
 
 export type PromotionList = PaginatedList & {
@@ -4634,6 +4637,7 @@ export type PromotionSortParameter = {
   perCustomerUsageLimit?: InputMaybe<SortOrder>;
   startsAt?: InputMaybe<SortOrder>;
   updatedAt?: InputMaybe<SortOrder>;
+  usageLimit?: InputMaybe<SortOrder>;
 };
 
 export type PromotionTranslation = {
@@ -6114,6 +6118,7 @@ export type UpdatePromotionInput = {
   perCustomerUsageLimit?: InputMaybe<Scalars['Int']>;
   startsAt?: InputMaybe<Scalars['DateTime']>;
   translations?: InputMaybe<Array<PromotionTranslationInput>>;
+  usageLimit?: InputMaybe<Scalars['Int']>;
 };
 
 export type UpdatePromotionResult = MissingConditionsError | Promotion;

+ 1 - 0
packages/payments-plugin/e2e/graphql/generated-shop-types.ts

@@ -2649,6 +2649,7 @@ export type Promotion = Node & {
   startsAt?: Maybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslation>;
   updatedAt: Scalars['DateTime'];
+  usageLimit?: Maybe<Scalars['Int']>;
 };
 
 export type PromotionList = PaginatedList & {

+ 1 - 0
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts

@@ -2796,6 +2796,7 @@ export type Promotion = Node & {
   startsAt?: Maybe<Scalars['DateTime']>;
   translations: Array<PromotionTranslation>;
   updatedAt: Scalars['DateTime'];
+  usageLimit?: Maybe<Scalars['Int']>;
 };
 
 export type PromotionList = PaginatedList & {

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
schema-admin.json


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
schema-shop.json


이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.