Explorar el Código

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

Relates to #1308
Michael Bromley hace 3 años
padre
commit
af3a705250

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 515 - 479
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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']>;
   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 = {
 export type CreateAddressInput = {
   fullName?: Maybe<Scalars['String']>;
   fullName?: Maybe<Scalars['String']>;
   company?: Maybe<Scalars['String']>;
   company?: Maybe<Scalars['String']>;
@@ -1411,7 +1436,10 @@ export enum ErrorCode {
   EMAIL_ADDRESS_CONFLICT_ERROR = 'EMAIL_ADDRESS_CONFLICT_ERROR',
   EMAIL_ADDRESS_CONFLICT_ERROR = 'EMAIL_ADDRESS_CONFLICT_ERROR',
   ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
   ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
   NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_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 = {
 export type ErrorResult = {
@@ -2245,6 +2273,7 @@ export type ModifyOrderInput = {
   note?: Maybe<Scalars['String']>;
   note?: Maybe<Scalars['String']>;
   refund?: Maybe<AdministratorRefundInput>;
   refund?: Maybe<AdministratorRefundInput>;
   options?: Maybe<ModifyOrderOptions>;
   options?: Maybe<ModifyOrderOptions>;
+  couponCodes?: Maybe<Array<Scalars['String']>>;
 };
 };
 
 
 export type ModifyOrderOptions = {
 export type ModifyOrderOptions = {
@@ -2252,7 +2281,7 @@ export type ModifyOrderOptions = {
   recalculateShipping?: Maybe<Scalars['Boolean']>;
   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 = {
 export type MoveCollectionInput = {
   collectionId: Scalars['ID'];
   collectionId: Scalars['ID'];

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 515 - 479
packages/core/e2e/graphql/generated-e2e-admin-types.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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 */
 /* tslint:disable:no-non-null-assertion */
 import { omit } from '@vendure/common/lib/omit';
 import { omit } from '@vendure/common/lib/omit';
 import { pick } from '@vendure/common/lib/pick';
 import { pick } from '@vendure/common/lib/pick';
+import { summate } from '@vendure/common/lib/shared-utils';
 import {
 import {
     defaultShippingCalculator,
     defaultShippingCalculator,
     defaultShippingEligibilityChecker,
     defaultShippingEligibilityChecker,
+    freeShipping,
+    manualFulfillmentHandler,
     mergeConfig,
     mergeConfig,
+    orderFixedDiscount,
+    orderPercentageDiscount,
     productsPercentageDiscount,
     productsPercentageDiscount,
     ShippingCalculator,
     ShippingCalculator,
 } from '@vendure/core';
 } from '@vendure/core';
@@ -14,9 +19,6 @@ import path from 'path';
 
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 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 {
 import {
     failsToSettlePaymentMethod,
     failsToSettlePaymentMethod,
@@ -28,16 +30,22 @@ import {
     AdminTransition,
     AdminTransition,
     CreateFulfillment,
     CreateFulfillment,
     CreatePromotion,
     CreatePromotion,
+    CreatePromotionMutation,
+    CreatePromotionMutationVariables,
     CreateShippingMethod,
     CreateShippingMethod,
     ErrorCode,
     ErrorCode,
     GetOrder,
     GetOrder,
     GetOrderHistory,
     GetOrderHistory,
     GetOrderWithModifications,
     GetOrderWithModifications,
+    GetOrderWithModificationsQuery,
+    GetOrderWithModificationsQueryVariables,
     GetStockMovement,
     GetStockMovement,
     GlobalFlag,
     GlobalFlag,
     HistoryEntryType,
     HistoryEntryType,
     LanguageCode,
     LanguageCode,
     ModifyOrder,
     ModifyOrder,
+    ModifyOrderMutation,
+    ModifyOrderMutationVariables,
     OrderFragment,
     OrderFragment,
     OrderWithLinesFragment,
     OrderWithLinesFragment,
     OrderWithModificationsFragment,
     OrderWithModificationsFragment,
@@ -60,7 +68,6 @@ import {
     CREATE_SHIPPING_METHOD,
     CREATE_SHIPPING_METHOD,
     GET_ORDER,
     GET_ORDER,
     GET_ORDER_HISTORY,
     GET_ORDER_HISTORY,
-    GET_PRODUCT_WITH_VARIANTS,
     GET_STOCK_MOVEMENT,
     GET_STOCK_MOVEMENT,
     UPDATE_CHANNEL,
     UPDATE_CHANNEL,
     UPDATE_PRODUCT_VARIANTS,
     UPDATE_PRODUCT_VARIANTS,
@@ -1455,6 +1462,7 @@ describe('Order modification', () => {
                 originalTotalWithTax - order.lines[0].proratedUnitPriceWithTax,
                 originalTotalWithTax - order.lines[0].proratedUnitPriceWithTax,
             );
             );
             expect(modifyOrder.payments![0].refunds![0].total).toBe(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) {
     async function adminTransitionOrderToState(id: string, state: string) {
         const result = await adminClient.query<AdminTransition.Mutation, AdminTransition.Variables>(
         const result = await adminClient.query<AdminTransition.Mutation, AdminTransition.Variables>(
             ADMIN_TRANSITION_TO_STATE,
             ADMIN_TRANSITION_TO_STATE,
@@ -1798,12 +2027,20 @@ describe('Order modification', () => {
         await adminTransitionOrderToState(order.id, 'Modifying');
         await adminTransitionOrderToState(order.id, 'Modifying');
         return order;
         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`
 export const ORDER_WITH_MODIFICATION_FRAGMENT = gql`
     fragment OrderWithModifications on Order {
     fragment OrderWithModifications on Order {
         id
         id
         state
         state
+        subTotal
+        subTotalWithTax
+        shipping
+        shippingWithTax
         total
         total
         totalWithTax
         totalWithTax
         lines {
         lines {
@@ -1812,6 +2049,11 @@ export const ORDER_WITH_MODIFICATION_FRAGMENT = gql`
             linePrice
             linePrice
             linePriceWithTax
             linePriceWithTax
             discountedLinePriceWithTax
             discountedLinePriceWithTax
+            proratedLinePriceWithTax
+            discounts {
+                description
+                amountWithTax
+            }
             productVariant {
             productVariant {
                 id
                 id
                 name
                 name
@@ -1870,6 +2112,17 @@ export const ORDER_WITH_MODIFICATION_FRAGMENT = gql`
                 paymentId
                 paymentId
             }
             }
         }
         }
+        promotions {
+            id
+            name
+            couponCode
+        }
+        discounts {
+            description
+            adjustmentSource
+            amount
+            amountWithTax
+        }
         shippingAddress {
         shippingAddress {
             streetLine1
             streetLine1
             city
             city

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

@@ -136,6 +136,7 @@ input ModifyOrderInput {
     note: String
     note: String
     refund: AdministratorRefundInput
     refund: AdministratorRefundInput
     options: ModifyOrderOptions
     options: ModifyOrderOptions
+    couponCodes: [String!]
 }
 }
 
 
 input AddItemInput {
 input AddItemInput {
@@ -367,4 +368,7 @@ union ModifyOrderResult =
     | OrderLimitError
     | OrderLimitError
     | NegativeQuantityError
     | NegativeQuantityError
     | InsufficientStockError
     | InsufficientStockError
+    | CouponCodeExpiredError
+    | CouponCodeInvalidError
+    | CouponCodeLimitError
 union AddManualPaymentToOrderResult = Order | ManualPaymentStateError
 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!
     quantityAvailable: Int!
     order: Order!
     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!
     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."
 "Returned when attempting to set the Customer for an Order when already logged in."
 type AlreadyLoggedInError implements ErrorResult {
 type AlreadyLoggedInError implements ErrorResult {
     errorCode: ErrorCode!
     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 {
 export class CreateFulfillmentError extends ErrorResult {
   readonly __typename = 'CreateFulfillmentError';
   readonly __typename = 'CreateFulfillmentError';
   readonly errorCode = 'CREATE_FULFILLMENT_ERROR' as any;
   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 {
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
   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 { 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 { ID } from '@vendure/common/lib/shared-types';
 import { summate } from '@vendure/common/lib/shared-utils';
 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 { isGraphQlErrorResult, JustErrorResults } from '../../../common/error/error-result';
 import { EntityNotFoundError, InternalServerError, UserInputError } from '../../../common/error/errors';
 import { EntityNotFoundError, InternalServerError, UserInputError } from '../../../common/error/errors';
 import {
 import {
+    CouponCodeExpiredError,
+    CouponCodeInvalidError,
+    CouponCodeLimitError,
     NoChangesSpecifiedError,
     NoChangesSpecifiedError,
     OrderModificationStateError,
     OrderModificationStateError,
     RefundPaymentIdMissingError,
     RefundPaymentIdMissingError,
@@ -16,6 +24,7 @@ import {
     NegativeQuantityError,
     NegativeQuantityError,
     OrderLimitError,
     OrderLimitError,
 } from '../../../common/error/generated-graphql-shop-errors';
 } from '../../../common/error/generated-graphql-shop-errors';
+import { AdjustmentSource } from '../../../common/types/adjustment-source';
 import { idsAreEqual } from '../../../common/utils';
 import { idsAreEqual } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
 import { ConfigService } from '../../../config/config.service';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
 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 { EventBus } from '../../../event-bus/event-bus';
 import { OrderLineEvent } from '../../../event-bus/index';
 import { OrderLineEvent } from '../../../event-bus/index';
 import { CountryService } from '../../services/country.service';
 import { CountryService } from '../../services/country.service';
+import { HistoryService } from '../../services/history.service';
 import { PaymentService } from '../../services/payment.service';
 import { PaymentService } from '../../services/payment.service';
 import { ProductVariantService } from '../../services/product-variant.service';
 import { ProductVariantService } from '../../services/product-variant.service';
+import { PromotionService } from '../../services/promotion.service';
 import { StockMovementService } from '../../services/stock-movement.service';
 import { StockMovementService } from '../../services/stock-movement.service';
 import { CustomFieldRelationService } from '../custom-field-relation/custom-field-relation.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 { OrderCalculator } from '../order-calculator/order-calculator';
 import { patchEntity } from '../utils/patch-entity';
 import { patchEntity } from '../utils/patch-entity';
 import { translateDeep } from '../utils/translate-entity';
 import { translateDeep } from '../utils/translate-entity';
@@ -61,7 +73,10 @@ export class OrderModifier {
         private stockMovementService: StockMovementService,
         private stockMovementService: StockMovementService,
         private productVariantService: ProductVariantService,
         private productVariantService: ProductVariantService,
         private customFieldRelationService: CustomFieldRelationService,
         private customFieldRelationService: CustomFieldRelationService,
+        private promotionService: PromotionService,
         private eventBus: EventBus,
         private eventBus: EventBus,
+        private entityHydrator: EntityHydrator,
+        private historyService: HistoryService,
     ) {}
     ) {}
 
 
     /**
     /**
@@ -397,6 +412,43 @@ export class OrderModifier {
             modification.billingAddressChange = input.updateBillingAddress;
             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 updatedOrderLines = order.lines.filter(l => updatedOrderLineIds.includes(l.id));
         const promotions = await this.connection.getRepository(ctx, Promotion).find({
         const promotions = await this.connection.getRepository(ctx, Promotion).find({
             where: { enabled: true, deletedAt: null },
             where: { enabled: true, deletedAt: null },
@@ -426,6 +478,7 @@ export class OrderModifier {
             if (shippingDelta < 0) {
             if (shippingDelta < 0) {
                 refundInput.shipping = shippingDelta * -1;
                 refundInput.shipping = shippingDelta * -1;
             }
             }
+            refundInput.adjustment += await this.getAdjustmentFromNewlyAppliedPromotions(ctx, order);
             const existingPayments = await this.getOrderPayments(ctx, order.id);
             const existingPayments = await this.getOrderPayments(ctx, order.id);
             const payment = existingPayments.find(p => idsAreEqual(p.id, input.refund?.paymentId));
             const payment = existingPayments.find(p => idsAreEqual(p.id, input.refund?.paymentId));
             if (payment) {
             if (payment) {
@@ -449,7 +502,18 @@ export class OrderModifier {
             .getRepository(ctx, OrderModification)
             .getRepository(ctx, OrderModification)
             .save(modification);
             .save(modification);
         await this.connection.getRepository(ctx, Order).save(order);
         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 });
         await this.connection.getRepository(ctx, ShippingLine).save(order.shippingLines, { reload: false });
         return { order, modification: createdModification };
         return { order, modification: createdModification };
     }
     }
@@ -461,10 +525,40 @@ export class OrderModifier {
             !input.surcharges?.length &&
             !input.surcharges?.length &&
             !input.updateShippingAddress &&
             !input.updateShippingAddress &&
             !input.updateBillingAddress &&
             !input.updateBillingAddress &&
+            !input.couponCodes &&
             !(input as any).customFields;
             !(input as any).customFields;
         return noChanges;
         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[]> {
     private getOrderPayments(ctx: RequestContext, orderId: ID): Promise<Payment[]> {
         return this.connection.getRepository(ctx, Payment).find({
         return this.connection.getRepository(ctx, Payment).find({
             relations: ['refunds'],
             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 {
 export function orderTotalIsCovered(order: Order, state: PaymentState | PaymentState[]): boolean {
     const paymentsTotal = totalCoveredByPayments(order, state);
     const paymentsTotal = totalCoveredByPayments(order, state);
-    return paymentsTotal === order.totalWithTax;
+    return paymentsTotal >= order.totalWithTax;
 }
 }
 
 
 /**
 /**

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 515 - 479
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 515 - 479
packages/payments-plugin/e2e/graphql/generated-admin-types.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 714 - 699
packages/payments-plugin/e2e/graphql/generated-shop-types.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 751 - 736
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
schema-admin.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
schema-shop.json


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio