소스 검색

feat(core): Allow couponCodes to be set when modifying Order

Relates to #1308
Michael Bromley 3 년 전
부모
커밋
af3a705250

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 515 - 479
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 751 - 736
packages/common/src/generated-shop-types.ts


+ 31 - 2
packages/common/src/generated-types.ts

@@ -559,6 +559,31 @@ export type CountryTranslationInput = {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeExpiredError = ErrorResult & {
+  __typename?: 'CouponCodeExpiredError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+  couponCode: Scalars['String'];
+};
+
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeInvalidError = ErrorResult & {
+  __typename?: 'CouponCodeInvalidError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+  couponCode: Scalars['String'];
+};
+
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeLimitError = ErrorResult & {
+  __typename?: 'CouponCodeLimitError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+  couponCode: Scalars['String'];
+  limit: Scalars['Int'];
+};
+
 export type CreateAddressInput = {
   fullName?: Maybe<Scalars['String']>;
   company?: Maybe<Scalars['String']>;
@@ -1411,7 +1436,10 @@ export enum ErrorCode {
   EMAIL_ADDRESS_CONFLICT_ERROR = 'EMAIL_ADDRESS_CONFLICT_ERROR',
   ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
   NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_ERROR',
-  INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR'
+  INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR',
+  COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
+  COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
+  COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR'
 }
 
 export type ErrorResult = {
@@ -2245,6 +2273,7 @@ export type ModifyOrderInput = {
   note?: Maybe<Scalars['String']>;
   refund?: Maybe<AdministratorRefundInput>;
   options?: Maybe<ModifyOrderOptions>;
+  couponCodes?: Maybe<Array<Scalars['String']>>;
 };
 
 export type ModifyOrderOptions = {
@@ -2252,7 +2281,7 @@ export type ModifyOrderOptions = {
   recalculateShipping?: Maybe<Scalars['Boolean']>;
 };
 
-export type ModifyOrderResult = Order | NoChangesSpecifiedError | OrderModificationStateError | PaymentMethodMissingError | RefundPaymentIdMissingError | OrderLimitError | NegativeQuantityError | InsufficientStockError;
+export type ModifyOrderResult = Order | NoChangesSpecifiedError | OrderModificationStateError | PaymentMethodMissingError | RefundPaymentIdMissingError | OrderLimitError | NegativeQuantityError | InsufficientStockError | CouponCodeExpiredError | CouponCodeInvalidError | CouponCodeLimitError;
 
 export type MoveCollectionInput = {
   collectionId: Scalars['ID'];

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 515 - 479
packages/core/e2e/graphql/generated-e2e-admin-types.ts


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 714 - 699
packages/core/e2e/graphql/generated-e2e-shop-types.ts


+ 257 - 4
packages/core/e2e/order-modification.e2e-spec.ts

@@ -1,10 +1,15 @@
 /* tslint:disable:no-non-null-assertion */
 import { omit } from '@vendure/common/lib/omit';
 import { pick } from '@vendure/common/lib/pick';
+import { summate } from '@vendure/common/lib/shared-utils';
 import {
     defaultShippingCalculator,
     defaultShippingEligibilityChecker,
+    freeShipping,
+    manualFulfillmentHandler,
     mergeConfig,
+    orderFixedDiscount,
+    orderPercentageDiscount,
     productsPercentageDiscount,
     ShippingCalculator,
 } from '@vendure/core';
@@ -14,9 +19,6 @@ import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
-import { manualFulfillmentHandler } from '../src/config/fulfillment/manual-fulfillment-handler';
-import { orderFixedDiscount } from '../src/config/promotion/actions/order-fixed-discount-action';
-import { defaultPromotionActions } from '../src/config/promotion/index';
 
 import {
     failsToSettlePaymentMethod,
@@ -28,16 +30,22 @@ import {
     AdminTransition,
     CreateFulfillment,
     CreatePromotion,
+    CreatePromotionMutation,
+    CreatePromotionMutationVariables,
     CreateShippingMethod,
     ErrorCode,
     GetOrder,
     GetOrderHistory,
     GetOrderWithModifications,
+    GetOrderWithModificationsQuery,
+    GetOrderWithModificationsQueryVariables,
     GetStockMovement,
     GlobalFlag,
     HistoryEntryType,
     LanguageCode,
     ModifyOrder,
+    ModifyOrderMutation,
+    ModifyOrderMutationVariables,
     OrderFragment,
     OrderWithLinesFragment,
     OrderWithModificationsFragment,
@@ -60,7 +68,6 @@ import {
     CREATE_SHIPPING_METHOD,
     GET_ORDER,
     GET_ORDER_HISTORY,
-    GET_PRODUCT_WITH_VARIANTS,
     GET_STOCK_MOVEMENT,
     UPDATE_CHANNEL,
     UPDATE_PRODUCT_VARIANTS,
@@ -1455,6 +1462,7 @@ describe('Order modification', () => {
                 originalTotalWithTax - order.lines[0].proratedUnitPriceWithTax,
             );
             expect(modifyOrder.payments![0].refunds![0].total).toBe(order.lines[0].proratedUnitPriceWithTax);
+            expect(modifyOrder.totalWithTax).toBe(getOrderPaymentsTotalWithRefunds(modifyOrder));
         });
     });
 
@@ -1736,6 +1744,227 @@ describe('Order modification', () => {
         });
     });
 
+    describe('couponCode handling', () => {
+        const CODE_50PC_OFF = '50PC';
+        const CODE_FREE_SHIPPING = 'FREESHIP';
+        let order: TestOrderWithPaymentsFragment;
+        beforeAll(async () => {
+            await adminClient.query<CreatePromotionMutation, CreatePromotionMutationVariables>(
+                CREATE_PROMOTION,
+                {
+                    input: {
+                        name: '50% off',
+                        couponCode: CODE_50PC_OFF,
+                        enabled: true,
+                        conditions: [],
+                        actions: [
+                            {
+                                code: orderPercentageDiscount.code,
+                                arguments: [{ name: 'discount', value: '50' }],
+                            },
+                        ],
+                    },
+                },
+            );
+            await adminClient.query<CreatePromotionMutation, CreatePromotionMutationVariables>(
+                CREATE_PROMOTION,
+                {
+                    input: {
+                        name: 'Free shipping',
+                        couponCode: CODE_FREE_SHIPPING,
+                        enabled: true,
+                        conditions: [],
+                        actions: [{ code: freeShipping.code, arguments: [] }],
+                    },
+                },
+            );
+
+            // create an order and check out
+            await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
+            await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
+                productVariantId: 'T_1',
+                quantity: 1,
+                customFields: {
+                    color: 'green',
+                },
+            } as any);
+            await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
+                productVariantId: 'T_4',
+                quantity: 2,
+            });
+            await proceedToArrangingPayment(shopClient);
+            const result = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+            orderGuard.assertSuccess(result);
+            order = result;
+            const result2 = await adminTransitionOrderToState(order.id, 'Modifying');
+            orderGuard.assertSuccess(result2);
+            expect(result2.state).toBe('Modifying');
+        });
+
+        it('invalid coupon code returns ErrorResult', async () => {
+            const { modifyOrder } = await adminClient.query<
+                ModifyOrderMutation,
+                ModifyOrderMutationVariables
+            >(MODIFY_ORDER, {
+                input: {
+                    dryRun: false,
+                    orderId: order.id,
+                    couponCodes: ['BAD_CODE'],
+                },
+            });
+            orderGuard.assertErrorResult(modifyOrder);
+            expect(modifyOrder.message).toBe('Coupon code "BAD_CODE" is not valid');
+        });
+
+        it('valid coupon code applies Promotion', async () => {
+            const { modifyOrder } = await adminClient.query<
+                ModifyOrderMutation,
+                ModifyOrderMutationVariables
+            >(MODIFY_ORDER, {
+                input: {
+                    dryRun: false,
+                    orderId: order.id,
+                    refund: {
+                        paymentId: order.payments![0].id,
+                    },
+                    couponCodes: [CODE_50PC_OFF],
+                },
+            });
+            orderGuard.assertSuccess(modifyOrder);
+            expect(modifyOrder.subTotalWithTax).toBe(order.subTotalWithTax * 0.5);
+        });
+
+        it('adds order.discounts', async () => {
+            const { order: orderWithModifications } = await adminClient.query<
+                GetOrderWithModificationsQuery,
+                GetOrderWithModificationsQueryVariables
+            >(GET_ORDER_WITH_MODIFICATIONS, { id: order.id });
+            expect(orderWithModifications?.discounts.length).toBe(1);
+            expect(orderWithModifications?.discounts[0].description).toBe('50% off');
+        });
+
+        it('adds order.promotions', async () => {
+            const { order: orderWithModifications } = await adminClient.query<
+                GetOrderWithModificationsQuery,
+                GetOrderWithModificationsQueryVariables
+            >(GET_ORDER_WITH_MODIFICATIONS, { id: order.id });
+            expect(orderWithModifications?.promotions.length).toBe(1);
+            expect(orderWithModifications?.promotions[0].name).toBe('50% off');
+        });
+
+        it('creates correct refund amount', async () => {
+            const { order: orderWithModifications } = await adminClient.query<
+                GetOrderWithModificationsQuery,
+                GetOrderWithModificationsQueryVariables
+            >(GET_ORDER_WITH_MODIFICATIONS, { id: order.id });
+            expect(orderWithModifications?.payments![0].refunds.length).toBe(1);
+            expect(orderWithModifications!.totalWithTax).toBe(
+                getOrderPaymentsTotalWithRefunds(orderWithModifications!),
+            );
+            expect(orderWithModifications?.payments![0].refunds[0].total).toBe(
+                order.totalWithTax - orderWithModifications!.totalWithTax,
+            );
+        });
+
+        it('creates history entry for applying couponCode', async () => {
+            const { order: history } = await adminClient.query<
+                GetOrderHistory.Query,
+                GetOrderHistory.Variables
+            >(GET_ORDER_HISTORY, {
+                id: order.id,
+                options: { filter: { type: { eq: HistoryEntryType.ORDER_COUPON_APPLIED } } },
+            });
+            orderGuard.assertSuccess(history);
+
+            expect(history.history.items.length).toBe(1);
+            expect(pick(history.history.items[0]!, ['type', 'data'])).toEqual({
+                type: HistoryEntryType.ORDER_COUPON_APPLIED,
+                data: { couponCode: CODE_50PC_OFF, promotionId: 'T_4' },
+            });
+        });
+
+        it('removes coupon code', async () => {
+            const { modifyOrder } = await adminClient.query<
+                ModifyOrderMutation,
+                ModifyOrderMutationVariables
+            >(MODIFY_ORDER, {
+                input: {
+                    dryRun: false,
+                    orderId: order.id,
+                    couponCodes: [],
+                },
+            });
+            orderGuard.assertSuccess(modifyOrder);
+            expect(modifyOrder.subTotalWithTax).toBe(order.subTotalWithTax);
+        });
+
+        it('removes order.discounts', async () => {
+            const { order: orderWithModifications } = await adminClient.query<
+                GetOrderWithModificationsQuery,
+                GetOrderWithModificationsQueryVariables
+            >(GET_ORDER_WITH_MODIFICATIONS, { id: order.id });
+            expect(orderWithModifications?.discounts.length).toBe(0);
+        });
+
+        it('removes order.promotions', async () => {
+            const { order: orderWithModifications } = await adminClient.query<
+                GetOrderWithModificationsQuery,
+                GetOrderWithModificationsQueryVariables
+            >(GET_ORDER_WITH_MODIFICATIONS, { id: order.id });
+            expect(orderWithModifications?.promotions.length).toBe(0);
+        });
+
+        it('creates history entry for removing couponCode', async () => {
+            const { order: history } = await adminClient.query<
+                GetOrderHistory.Query,
+                GetOrderHistory.Variables
+            >(GET_ORDER_HISTORY, {
+                id: order.id,
+                options: { filter: { type: { eq: HistoryEntryType.ORDER_COUPON_REMOVED } } },
+            });
+            orderGuard.assertSuccess(history);
+
+            expect(history.history.items.length).toBe(1);
+            expect(pick(history.history.items[0]!, ['type', 'data'])).toEqual({
+                type: HistoryEntryType.ORDER_COUPON_REMOVED,
+                data: { couponCode: CODE_50PC_OFF },
+            });
+        });
+
+        it('correct refund for free shipping couponCode', async () => {
+            await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
+                productVariantId: 'T_1',
+                quantity: 1,
+            } as any);
+            await proceedToArrangingPayment(shopClient);
+            const result = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+            orderGuard.assertSuccess(result);
+            const order2 = result;
+            const shippingWithTax = order2.shippingWithTax;
+            const result2 = await adminTransitionOrderToState(order2.id, 'Modifying');
+            orderGuard.assertSuccess(result2);
+            expect(result2.state).toBe('Modifying');
+
+            const { modifyOrder } = await adminClient.query<
+                ModifyOrderMutation,
+                ModifyOrderMutationVariables
+            >(MODIFY_ORDER, {
+                input: {
+                    dryRun: false,
+                    orderId: order2.id,
+                    refund: {
+                        paymentId: order2.payments![0].id,
+                    },
+                    couponCodes: [CODE_FREE_SHIPPING],
+                },
+            });
+            orderGuard.assertSuccess(modifyOrder);
+            expect(modifyOrder.shippingWithTax).toBe(0);
+            expect(modifyOrder!.totalWithTax).toBe(getOrderPaymentsTotalWithRefunds(modifyOrder!));
+            expect(modifyOrder.payments![0].refunds[0].total).toBe(shippingWithTax);
+        });
+    });
+
     async function adminTransitionOrderToState(id: string, state: string) {
         const result = await adminClient.query<AdminTransition.Mutation, AdminTransition.Variables>(
             ADMIN_TRANSITION_TO_STATE,
@@ -1798,12 +2027,20 @@ describe('Order modification', () => {
         await adminTransitionOrderToState(order.id, 'Modifying');
         return order;
     }
+
+    function getOrderPaymentsTotalWithRefunds(_order: OrderWithModificationsFragment) {
+        return _order.payments?.reduce((sum, p) => sum + p.amount - summate(p?.refunds, 'total'), 0) ?? 0;
+    }
 });
 
 export const ORDER_WITH_MODIFICATION_FRAGMENT = gql`
     fragment OrderWithModifications on Order {
         id
         state
+        subTotal
+        subTotalWithTax
+        shipping
+        shippingWithTax
         total
         totalWithTax
         lines {
@@ -1812,6 +2049,11 @@ export const ORDER_WITH_MODIFICATION_FRAGMENT = gql`
             linePrice
             linePriceWithTax
             discountedLinePriceWithTax
+            proratedLinePriceWithTax
+            discounts {
+                description
+                amountWithTax
+            }
             productVariant {
                 id
                 name
@@ -1870,6 +2112,17 @@ export const ORDER_WITH_MODIFICATION_FRAGMENT = gql`
                 paymentId
             }
         }
+        promotions {
+            id
+            name
+            couponCode
+        }
+        discounts {
+            description
+            adjustmentSource
+            amount
+            amountWithTax
+        }
         shippingAddress {
             streetLine1
             city

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

@@ -136,6 +136,7 @@ input ModifyOrderInput {
     note: String
     refund: AdministratorRefundInput
     options: ModifyOrderOptions
+    couponCodes: [String!]
 }
 
 input AddItemInput {
@@ -367,4 +368,7 @@ union ModifyOrderResult =
     | OrderLimitError
     | NegativeQuantityError
     | InsufficientStockError
+    | CouponCodeExpiredError
+    | CouponCodeInvalidError
+    | CouponCodeLimitError
 union AddManualPaymentToOrderResult = Order | ManualPaymentStateError

+ 22 - 0
packages/core/src/api/schema/common/common-error-results.graphql

@@ -46,3 +46,25 @@ type InsufficientStockError implements ErrorResult {
     quantityAvailable: Int!
     order: Order!
 }
+
+"Returned if the provided coupon code is invalid"
+type CouponCodeInvalidError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    couponCode: String!
+}
+
+"Returned if the provided coupon code is invalid"
+type CouponCodeExpiredError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    couponCode: String!
+}
+
+"Returned if the provided coupon code is invalid"
+type CouponCodeLimitError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    couponCode: String!
+    limit: Int!
+}

+ 0 - 29
packages/core/src/api/schema/shop-api/shop-error-results.graphql

@@ -37,35 +37,6 @@ type PaymentDeclinedError implements ErrorResult {
     paymentErrorMessage: String!
 }
 
-"Returned if the provided coupon code is invalid"
-type CouponCodeInvalidError implements ErrorResult {
-    errorCode: ErrorCode!
-    message: String!
-    couponCode: String!
-}
-
-"Returned if the provided coupon code is invalid"
-type CouponCodeInvalidError implements ErrorResult {
-    errorCode: ErrorCode!
-    message: String!
-    couponCode: String!
-}
-
-"Returned if the provided coupon code is invalid"
-type CouponCodeExpiredError implements ErrorResult {
-    errorCode: ErrorCode!
-    message: String!
-    couponCode: String!
-}
-
-"Returned if the provided coupon code is invalid"
-type CouponCodeLimitError implements ErrorResult {
-    errorCode: ErrorCode!
-    message: String!
-    couponCode: String!
-    limit: Int!
-}
-
 "Returned when attempting to set the Customer for an Order when already logged in."
 type AlreadyLoggedInError implements ErrorResult {
     errorCode: ErrorCode!

+ 35 - 1
packages/core/src/common/error/generated-graphql-admin-errors.ts

@@ -52,6 +52,40 @@ export class ChannelDefaultLanguageError extends ErrorResult {
   }
 }
 
+export class CouponCodeExpiredError extends ErrorResult {
+  readonly __typename = 'CouponCodeExpiredError';
+  readonly errorCode = 'COUPON_CODE_EXPIRED_ERROR' as any;
+  readonly message = 'COUPON_CODE_EXPIRED_ERROR';
+  constructor(
+    public couponCode: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+export class CouponCodeInvalidError extends ErrorResult {
+  readonly __typename = 'CouponCodeInvalidError';
+  readonly errorCode = 'COUPON_CODE_INVALID_ERROR' as any;
+  readonly message = 'COUPON_CODE_INVALID_ERROR';
+  constructor(
+    public couponCode: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+export class CouponCodeLimitError extends ErrorResult {
+  readonly __typename = 'CouponCodeLimitError';
+  readonly errorCode = 'COUPON_CODE_LIMIT_ERROR' as any;
+  readonly message = 'COUPON_CODE_LIMIT_ERROR';
+  constructor(
+    public couponCode: Scalars['String'],
+    public limit: Scalars['Int'],
+  ) {
+    super();
+  }
+}
+
 export class CreateFulfillmentError extends ErrorResult {
   readonly __typename = 'CreateFulfillmentError';
   readonly errorCode = 'CREATE_FULFILLMENT_ERROR' as any;
@@ -380,7 +414,7 @@ export class SettlePaymentError extends ErrorResult {
 }
 
 
-const errorTypeNames = new Set(['AlreadyRefundedError', 'CancelActiveOrderError', 'ChannelDefaultLanguageError', 'CreateFulfillmentError', 'EmailAddressConflictError', 'EmptyOrderLineSelectionError', 'FulfillmentStateTransitionError', 'InsufficientStockError', 'InsufficientStockOnHandError', 'InvalidCredentialsError', 'InvalidFulfillmentHandlerError', 'ItemsAlreadyFulfilledError', 'LanguageNotAvailableError', 'ManualPaymentStateError', 'MimeTypeError', 'MissingConditionsError', 'MultipleOrderError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoChangesSpecifiedError', 'NothingToRefundError', 'OrderLimitError', 'OrderModificationStateError', 'OrderStateTransitionError', 'PaymentMethodMissingError', 'PaymentOrderMismatchError', 'PaymentStateTransitionError', 'ProductOptionInUseError', 'QuantityTooGreatError', 'RefundOrderStateError', 'RefundPaymentIdMissingError', 'RefundStateTransitionError', 'SettlePaymentError']);
+const errorTypeNames = new Set(['AlreadyRefundedError', 'CancelActiveOrderError', 'ChannelDefaultLanguageError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'CreateFulfillmentError', 'EmailAddressConflictError', 'EmptyOrderLineSelectionError', 'FulfillmentStateTransitionError', 'InsufficientStockError', 'InsufficientStockOnHandError', 'InvalidCredentialsError', 'InvalidFulfillmentHandlerError', 'ItemsAlreadyFulfilledError', 'LanguageNotAvailableError', 'ManualPaymentStateError', 'MimeTypeError', 'MissingConditionsError', 'MultipleOrderError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoChangesSpecifiedError', 'NothingToRefundError', 'OrderLimitError', 'OrderModificationStateError', 'OrderStateTransitionError', 'PaymentMethodMissingError', 'PaymentOrderMismatchError', 'PaymentStateTransitionError', 'ProductOptionInUseError', 'QuantityTooGreatError', 'RefundOrderStateError', 'RefundPaymentIdMissingError', 'RefundStateTransitionError', 'SettlePaymentError']);
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
 }

+ 96 - 2
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -1,5 +1,10 @@
 import { Injectable } from '@nestjs/common';
-import { ModifyOrderInput, ModifyOrderResult, RefundOrderInput } from '@vendure/common/lib/generated-types';
+import {
+    HistoryEntryType,
+    ModifyOrderInput,
+    ModifyOrderResult,
+    RefundOrderInput,
+} from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { summate } from '@vendure/common/lib/shared-utils';
 
@@ -7,6 +12,9 @@ import { RequestContext } from '../../../api/common/request-context';
 import { isGraphQlErrorResult, JustErrorResults } from '../../../common/error/error-result';
 import { EntityNotFoundError, InternalServerError, UserInputError } from '../../../common/error/errors';
 import {
+    CouponCodeExpiredError,
+    CouponCodeInvalidError,
+    CouponCodeLimitError,
     NoChangesSpecifiedError,
     OrderModificationStateError,
     RefundPaymentIdMissingError,
@@ -16,6 +24,7 @@ import {
     NegativeQuantityError,
     OrderLimitError,
 } from '../../../common/error/generated-graphql-shop-errors';
+import { AdjustmentSource } from '../../../common/types/adjustment-source';
 import { idsAreEqual } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
@@ -31,10 +40,13 @@ import { Surcharge } from '../../../entity/surcharge/surcharge.entity';
 import { EventBus } from '../../../event-bus/event-bus';
 import { OrderLineEvent } from '../../../event-bus/index';
 import { CountryService } from '../../services/country.service';
+import { HistoryService } from '../../services/history.service';
 import { PaymentService } from '../../services/payment.service';
 import { ProductVariantService } from '../../services/product-variant.service';
+import { PromotionService } from '../../services/promotion.service';
 import { StockMovementService } from '../../services/stock-movement.service';
 import { CustomFieldRelationService } from '../custom-field-relation/custom-field-relation.service';
+import { EntityHydrator } from '../entity-hydrator/entity-hydrator.service';
 import { OrderCalculator } from '../order-calculator/order-calculator';
 import { patchEntity } from '../utils/patch-entity';
 import { translateDeep } from '../utils/translate-entity';
@@ -61,7 +73,10 @@ export class OrderModifier {
         private stockMovementService: StockMovementService,
         private productVariantService: ProductVariantService,
         private customFieldRelationService: CustomFieldRelationService,
+        private promotionService: PromotionService,
         private eventBus: EventBus,
+        private entityHydrator: EntityHydrator,
+        private historyService: HistoryService,
     ) {}
 
     /**
@@ -397,6 +412,43 @@ export class OrderModifier {
             modification.billingAddressChange = input.updateBillingAddress;
         }
 
+        if (input.couponCodes) {
+            for (const couponCode of input.couponCodes) {
+                const validationResult = await this.promotionService.validateCouponCode(
+                    ctx,
+                    couponCode,
+                    order.customer && order.customer.id,
+                );
+                if (isGraphQlErrorResult(validationResult)) {
+                    return validationResult as
+                        | CouponCodeExpiredError
+                        | CouponCodeInvalidError
+                        | CouponCodeLimitError;
+                }
+                if (!order.couponCodes.includes(couponCode)) {
+                    // This is a new coupon code that hadn't been applied before
+                    await this.historyService.createHistoryEntryForOrder({
+                        ctx,
+                        orderId: order.id,
+                        type: HistoryEntryType.ORDER_COUPON_APPLIED,
+                        data: { couponCode, promotionId: validationResult.id },
+                    });
+                }
+            }
+            for (const existingCouponCode of order.couponCodes) {
+                if (!input.couponCodes.includes(existingCouponCode)) {
+                    // An existing coupon code has been removed
+                    await this.historyService.createHistoryEntryForOrder({
+                        ctx,
+                        orderId: order.id,
+                        type: HistoryEntryType.ORDER_COUPON_REMOVED,
+                        data: { couponCode: existingCouponCode },
+                    });
+                }
+            }
+            order.couponCodes = input.couponCodes;
+        }
+
         const updatedOrderLines = order.lines.filter(l => updatedOrderLineIds.includes(l.id));
         const promotions = await this.connection.getRepository(ctx, Promotion).find({
             where: { enabled: true, deletedAt: null },
@@ -426,6 +478,7 @@ export class OrderModifier {
             if (shippingDelta < 0) {
                 refundInput.shipping = shippingDelta * -1;
             }
+            refundInput.adjustment += await this.getAdjustmentFromNewlyAppliedPromotions(ctx, order);
             const existingPayments = await this.getOrderPayments(ctx, order.id);
             const payment = existingPayments.find(p => idsAreEqual(p.id, input.refund?.paymentId));
             if (payment) {
@@ -449,7 +502,18 @@ export class OrderModifier {
             .getRepository(ctx, OrderModification)
             .save(modification);
         await this.connection.getRepository(ctx, Order).save(order);
-        await this.connection.getRepository(ctx, OrderItem).save(modification.orderItems, { reload: false });
+        if (input.couponCodes) {
+            // When coupon codes have changed, this will likely affect the adjustments applied to
+            // OrderItems. So in this case we need to save all of them.
+            const orderItems = order.lines.reduce((all, line) => all.concat(line.items), [] as OrderItem[]);
+            await this.connection.getRepository(ctx, OrderItem).save(orderItems, { reload: false });
+            await this.promotionService.addPromotionsToOrder(ctx, order);
+        } else {
+            // Otherwise, just save those OrderItems that were specifically added/removed
+            await this.connection
+                .getRepository(ctx, OrderItem)
+                .save(modification.orderItems, { reload: false });
+        }
         await this.connection.getRepository(ctx, ShippingLine).save(order.shippingLines, { reload: false });
         return { order, modification: createdModification };
     }
@@ -461,10 +525,40 @@ export class OrderModifier {
             !input.surcharges?.length &&
             !input.updateShippingAddress &&
             !input.updateBillingAddress &&
+            !input.couponCodes &&
             !(input as any).customFields;
         return noChanges;
     }
 
+    private async getAdjustmentFromNewlyAppliedPromotions(ctx: RequestContext, order: Order) {
+        await this.entityHydrator.hydrate(ctx, order, { relations: ['promotions'] });
+        const existingPromotions = order.promotions;
+        const newPromotionDiscounts = order.discounts
+            .filter(discount => {
+                const promotionId = AdjustmentSource.decodeSourceId(discount.adjustmentSource).id;
+                return !existingPromotions.find(p => idsAreEqual(p.id, promotionId));
+            })
+            .filter(discount => {
+                // Filter out any discounts that originate from ShippingLine discounts,
+                // since they are already correctly accounted for in the refund calculation.
+                for (const shippingLine of order.shippingLines) {
+                    if (
+                        shippingLine.discounts.find(
+                            shippingDiscount =>
+                                shippingDiscount.adjustmentSource === discount.adjustmentSource,
+                        )
+                    ) {
+                        return false;
+                    }
+                }
+                return true;
+            });
+        if (newPromotionDiscounts.length) {
+            return -summate(newPromotionDiscounts, 'amountWithTax');
+        }
+        return 0;
+    }
+
     private getOrderPayments(ctx: RequestContext, orderId: ID): Promise<Payment[]> {
         return this.connection.getRepository(ctx, Payment).find({
             relations: ['refunds'],

+ 1 - 1
packages/core/src/service/helpers/utils/order-utils.ts

@@ -9,7 +9,7 @@ import { PaymentState } from '../payment-state-machine/payment-state';
  */
 export function orderTotalIsCovered(order: Order, state: PaymentState | PaymentState[]): boolean {
     const paymentsTotal = totalCoveredByPayments(order, state);
-    return paymentsTotal === order.totalWithTax;
+    return paymentsTotal >= order.totalWithTax;
 }
 
 /**

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 515 - 479
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 515 - 479
packages/payments-plugin/e2e/graphql/generated-admin-types.ts


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 714 - 699
packages/payments-plugin/e2e/graphql/generated-shop-types.ts


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 751 - 736
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts


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


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


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