Răsfoiți Sursa

feat(core): Implement order modification

Relates to #314

BREAKING CHANGE: In order to support order modification, a couple of new default order states
have been created - `Modifying` and `ArrangingAdditionalPayment`. Also a new DB entity,
`OrderModification` has been created.
Michael Bromley 5 ani în urmă
părinte
comite
9cd3e2470d
38 a modificat fișierele cu 2986 adăugiri și 109 ștergeri
  1. 197 15
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 18 1
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  3. 169 13
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  4. 15 2
      packages/common/src/generated-shop-types.ts
  5. 171 14
      packages/common/src/generated-types.ts
  6. 13 0
      packages/core/e2e/graphql/fragments.ts
  7. 298 14
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  8. 23 4
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  9. 5 0
      packages/core/e2e/graphql/shop-definitions.ts
  10. 2 2
      packages/core/e2e/localization.e2e-spec.ts
  11. 1180 0
      packages/core/e2e/order-modification.e2e-spec.ts
  12. 19 0
      packages/core/src/api/resolvers/admin/order.resolver.ts
  13. 18 0
      packages/core/src/api/resolvers/entity/order-entity.resolver.ts
  14. 14 0
      packages/core/src/api/schema/admin-api/order-admin.type.graphql
  15. 131 0
      packages/core/src/api/schema/admin-api/order.api.graphql
  16. 61 1
      packages/core/src/common/error/generated-graphql-admin-errors.ts
  17. 1 1
      packages/core/src/common/utils.ts
  18. 2 0
      packages/core/src/entity/entities.ts
  19. 62 0
      packages/core/src/entity/order-modification/order-modification.entity.ts
  20. 4 0
      packages/core/src/entity/order/order.entity.ts
  21. 4 0
      packages/core/src/entity/surcharge/surcharge.entity.ts
  22. 3 0
      packages/core/src/i18n/messages/en.json
  23. 7 7
      packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts
  24. 7 4
      packages/core/src/service/helpers/order-calculator/order-calculator.ts
  25. 23 0
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  26. 30 0
      packages/core/src/service/helpers/order-state-machine/order-state-machine.ts
  27. 20 5
      packages/core/src/service/helpers/order-state-machine/order-state.ts
  28. 19 4
      packages/core/src/service/helpers/utils/order-utils.ts
  29. 2 0
      packages/core/src/service/index.ts
  30. 2 0
      packages/core/src/service/service.module.ts
  31. 1 0
      packages/core/src/service/services/order-testing.service.ts
  32. 258 8
      packages/core/src/service/services/order.service.ts
  33. 24 1
      packages/core/src/service/services/payment-method.service.ts
  34. 13 0
      packages/core/src/service/transaction/transactional-connection.ts
  35. 1 0
      packages/core/src/testing/order-test-utils.ts
  36. 169 13
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  37. 0 0
      schema-admin.json
  38. 0 0
      schema-shop.json

+ 197 - 15
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -290,6 +290,13 @@ export type Mutation = {
   /** Add Customers to a CustomerGroup */
   addCustomersToGroup: CustomerGroup;
   addFulfillmentToOrder: AddFulfillmentToOrderResult;
+  /**
+   * Used to manually create a new Payment against an Order. This is used when a completed Order
+   * has been modified (using `modifyOrder`) and the price has increased. The extra payment
+   * can then be manually arranged by the administrator, and the details used to create a new
+   * Payment.
+   */
+  addManualPaymentToOrder: AddManualPaymentToOrderResult;
   /** Add members to a Zone */
   addMembersToZone: Zone;
   addNoteToCustomer: Customer;
@@ -387,6 +394,11 @@ export type Mutation = {
   /** Authenticates the user using the native authentication strategy. This mutation is an alias for `authenticate({ native: { ... }})` */
   login: NativeAuthenticationResult;
   logout: Success;
+  /**
+   * Allows an Order to be modified after it has been completed by the Customer. The Order must first
+   * be in the `Modifying` state.
+   */
+  modifyOrder: ModifyOrderResult;
   /** Move a Collection to a different parent or index */
   moveCollection: Collection;
   refundOrder: RefundOrderResult;
@@ -475,6 +487,11 @@ export type MutationAddFulfillmentToOrderArgs = {
 };
 
 
+export type MutationAddManualPaymentToOrderArgs = {
+  input: ManualPaymentInput;
+};
+
+
 export type MutationAddMembersToZoneArgs = {
   zoneId: Scalars['ID'];
   memberIds: Array<Scalars['ID']>;
@@ -747,6 +764,11 @@ export type MutationLoginArgs = {
 };
 
 
+export type MutationModifyOrderArgs = {
+  input: ModifyOrderInput;
+};
+
+
 export type MutationMoveCollectionArgs = {
   input: MoveCollectionInput;
 };
@@ -1287,19 +1309,6 @@ export type UpdateFacetValueInput = {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type Fulfillment = Node & {
-  __typename?: 'Fulfillment';
-  nextStates: Array<Scalars['String']>;
-  id: Scalars['ID'];
-  createdAt: Scalars['DateTime'];
-  updatedAt: Scalars['DateTime'];
-  orderItems: Array<OrderItem>;
-  state: Scalars['String'];
-  method: Scalars['String'];
-  trackingCode?: Maybe<Scalars['String']>;
-  customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type UpdateGlobalSettingsInput = {
   availableLanguages?: Maybe<Array<LanguageCode>>;
   trackInventory?: Maybe<Scalars['Boolean']>;
@@ -1417,6 +1426,7 @@ export type JobQueue = {
 export type Order = Node & {
   __typename?: 'Order';
   nextStates: Array<Scalars['String']>;
+  modifications: Array<OrderModification>;
   id: Scalars['ID'];
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
@@ -1482,6 +1492,33 @@ export type OrderHistoryArgs = {
   options?: Maybe<HistoryEntryListOptions>;
 };
 
+export type Fulfillment = Node & {
+  __typename?: 'Fulfillment';
+  nextStates: Array<Scalars['String']>;
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  orderItems: Array<OrderItem>;
+  state: Scalars['String'];
+  method: Scalars['String'];
+  trackingCode?: Maybe<Scalars['String']>;
+  customFields?: Maybe<Scalars['JSON']>;
+};
+
+export type OrderModification = Node & {
+  __typename?: 'OrderModification';
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  priceChange: Scalars['Int'];
+  note: Scalars['String'];
+  orderItems?: Maybe<Array<OrderItem>>;
+  surcharges?: Maybe<Array<Surcharge>>;
+  payment?: Maybe<Payment>;
+  refund?: Maybe<Refund>;
+  isSettled: Scalars['Boolean'];
+};
+
 export type UpdateOrderInput = {
   id: Scalars['ID'];
   customFields?: Maybe<Scalars['JSON']>;
@@ -1530,6 +1567,67 @@ export type UpdateOrderNoteInput = {
   isPublic?: Maybe<Scalars['Boolean']>;
 };
 
+export type AdministratorPaymentInput = {
+  paymentMethod?: Maybe<Scalars['String']>;
+  metadata?: Maybe<Scalars['JSON']>;
+};
+
+export type AdministratorRefundInput = {
+  paymentId: Scalars['ID'];
+  reason?: Maybe<Scalars['String']>;
+};
+
+export type ModifyOrderOptions = {
+  freezePromotions?: Maybe<Scalars['Boolean']>;
+  recalculateShipping?: Maybe<Scalars['Boolean']>;
+};
+
+export type UpdateOrderAddressInput = {
+  fullName?: Maybe<Scalars['String']>;
+  company?: Maybe<Scalars['String']>;
+  streetLine1?: Maybe<Scalars['String']>;
+  streetLine2?: Maybe<Scalars['String']>;
+  city?: Maybe<Scalars['String']>;
+  province?: Maybe<Scalars['String']>;
+  postalCode?: Maybe<Scalars['String']>;
+  countryCode?: Maybe<Scalars['String']>;
+  phoneNumber?: Maybe<Scalars['String']>;
+};
+
+export type ModifyOrderInput = {
+  dryRun: Scalars['Boolean'];
+  orderId: Scalars['ID'];
+  addItems?: Maybe<Array<AddItemInput>>;
+  adjustOrderLines?: Maybe<Array<OrderLineInput>>;
+  surcharges?: Maybe<Array<SurchargeInput>>;
+  updateShippingAddress?: Maybe<UpdateOrderAddressInput>;
+  updateBillingAddress?: Maybe<UpdateOrderAddressInput>;
+  note?: Maybe<Scalars['String']>;
+  refund?: Maybe<AdministratorRefundInput>;
+  options?: Maybe<ModifyOrderOptions>;
+};
+
+export type AddItemInput = {
+  productVariantId: Scalars['ID'];
+  quantity: Scalars['Int'];
+};
+
+export type SurchargeInput = {
+  description: Scalars['String'];
+  sku?: Maybe<Scalars['String']>;
+  price: Scalars['Int'];
+  priceIncludesTax: Scalars['Boolean'];
+  taxRate?: Maybe<Scalars['Float']>;
+  taxDescription?: Maybe<Scalars['String']>;
+};
+
+export type ManualPaymentInput = {
+  orderId: Scalars['ID'];
+  method: Scalars['String'];
+  transactionId?: Maybe<Scalars['String']>;
+  metadata?: Maybe<Scalars['JSON']>;
+};
+
 /** Returned if the Payment settlement fails */
 export type SettlePaymentError = ErrorResult & {
   __typename?: 'SettlePaymentError';
@@ -1662,6 +1760,50 @@ export type FulfillmentStateTransitionError = ErrorResult & {
   toState: Scalars['String'];
 };
 
+/** Returned when attempting to modify the contents of an Order that is not in the `Modifying` state. */
+export type OrderModificationStateError = ErrorResult & {
+  __typename?: 'OrderModificationStateError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+};
+
+/** Returned when a call to modifyOrder fails to specify any changes */
+export type NoChangesSpecifiedError = ErrorResult & {
+  __typename?: 'NoChangesSpecifiedError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to modifyOrder fails to include a paymentMethod even
+ * though the price has increased as a result of the changes.
+ */
+export type PaymentMethodMissingError = ErrorResult & {
+  __typename?: 'PaymentMethodMissingError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to modifyOrder fails to include a refundPaymentId even
+ * though the price has decreased as a result of the changes.
+ */
+export type RefundPaymentIdMissingError = ErrorResult & {
+  __typename?: 'RefundPaymentIdMissingError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to addManualPaymentToOrder is made but the Order
+ * is not in the required state.
+ */
+export type ManualPaymentStateError = ErrorResult & {
+  __typename?: 'ManualPaymentStateError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+};
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 export type SettlePaymentResult = Payment | SettlePaymentError | PaymentStateTransitionError | OrderStateTransitionError;
@@ -1676,6 +1818,10 @@ export type SettleRefundResult = Refund | RefundStateTransitionError;
 
 export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
 
+export type ModifyOrderResult = Order | NoChangesSpecifiedError | OrderModificationStateError | PaymentMethodMissingError | RefundPaymentIdMissingError | OrderLimitError | NegativeQuantityError | InsufficientStockError;
+
+export type AddManualPaymentToOrderResult = Order | ManualPaymentStateError;
+
 export type PaymentMethodList = PaginatedList & {
   __typename?: 'PaymentMethodList';
   items: Array<PaymentMethod>;
@@ -2405,6 +2551,11 @@ export enum ErrorCode {
   REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
   PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
   FULFILLMENT_STATE_TRANSITION_ERROR = 'FULFILLMENT_STATE_TRANSITION_ERROR',
+  ORDER_MODIFICATION_STATE_ERROR = 'ORDER_MODIFICATION_STATE_ERROR',
+  NO_CHANGES_SPECIFIED_ERROR = 'NO_CHANGES_SPECIFIED_ERROR',
+  PAYMENT_METHOD_MISSING_ERROR = 'PAYMENT_METHOD_MISSING_ERROR',
+  REFUND_PAYMENT_ID_MISSING_ERROR = 'REFUND_PAYMENT_ID_MISSING_ERROR',
+  MANUAL_PAYMENT_STATE_ERROR = 'MANUAL_PAYMENT_STATE_ERROR',
   PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
   MISSING_CONDITIONS_ERROR = 'MISSING_CONDITIONS_ERROR',
   NATIVE_AUTH_STRATEGY_ERROR = 'NATIVE_AUTH_STRATEGY_ERROR',
@@ -3682,7 +3833,7 @@ export type OrderLine = Node & {
   discounts: Array<Adjustment>;
   taxLines: Array<TaxLine>;
   order: Order;
-  customFields?: Maybe<Scalars['JSON']>;
+  customFields?: Maybe<OrderLineCustomFields>;
 };
 
 export type Payment = Node & {
@@ -4427,6 +4578,12 @@ export type HistoryEntrySortParameter = {
   updatedAt?: Maybe<SortOrder>;
 };
 
+export type OrderLineCustomFields = {
+  __typename?: 'OrderLineCustomFields';
+  test?: Maybe<Scalars['String']>;
+  test2?: Maybe<Scalars['String']>;
+};
+
 export type AuthenticationInput = {
   native?: Maybe<NativeAuthInput>;
 };
@@ -7285,6 +7442,31 @@ type ErrorResult_FulfillmentStateTransitionError_Fragment = (
   & Pick<FulfillmentStateTransitionError, 'errorCode' | 'message'>
 );
 
+type ErrorResult_OrderModificationStateError_Fragment = (
+  { __typename?: 'OrderModificationStateError' }
+  & Pick<OrderModificationStateError, 'errorCode' | 'message'>
+);
+
+type ErrorResult_NoChangesSpecifiedError_Fragment = (
+  { __typename?: 'NoChangesSpecifiedError' }
+  & Pick<NoChangesSpecifiedError, 'errorCode' | 'message'>
+);
+
+type ErrorResult_PaymentMethodMissingError_Fragment = (
+  { __typename?: 'PaymentMethodMissingError' }
+  & Pick<PaymentMethodMissingError, 'errorCode' | 'message'>
+);
+
+type ErrorResult_RefundPaymentIdMissingError_Fragment = (
+  { __typename?: 'RefundPaymentIdMissingError' }
+  & Pick<RefundPaymentIdMissingError, 'errorCode' | 'message'>
+);
+
+type ErrorResult_ManualPaymentStateError_Fragment = (
+  { __typename?: 'ManualPaymentStateError' }
+  & Pick<ManualPaymentStateError, 'errorCode' | 'message'>
+);
+
 type ErrorResult_ProductOptionInUseError_Fragment = (
   { __typename?: 'ProductOptionInUseError' }
   & Pick<ProductOptionInUseError, 'errorCode' | 'message'>
@@ -7330,7 +7512,7 @@ type ErrorResult_InsufficientStockError_Fragment = (
   & Pick<InsufficientStockError, 'errorCode' | 'message'>
 );
 
-export type ErrorResultFragment = ErrorResult_MimeTypeError_Fragment | ErrorResult_LanguageNotAvailableError_Fragment | ErrorResult_ChannelDefaultLanguageError_Fragment | ErrorResult_SettlePaymentError_Fragment | ErrorResult_EmptyOrderLineSelectionError_Fragment | ErrorResult_ItemsAlreadyFulfilledError_Fragment | ErrorResult_InvalidFulfillmentHandlerError_Fragment | ErrorResult_CreateFulfillmentError_Fragment | ErrorResult_InsufficientStockOnHandError_Fragment | ErrorResult_MultipleOrderError_Fragment | ErrorResult_CancelActiveOrderError_Fragment | ErrorResult_PaymentOrderMismatchError_Fragment | ErrorResult_RefundOrderStateError_Fragment | ErrorResult_NothingToRefundError_Fragment | ErrorResult_AlreadyRefundedError_Fragment | ErrorResult_QuantityTooGreatError_Fragment | ErrorResult_RefundStateTransitionError_Fragment | ErrorResult_PaymentStateTransitionError_Fragment | ErrorResult_FulfillmentStateTransitionError_Fragment | ErrorResult_ProductOptionInUseError_Fragment | ErrorResult_MissingConditionsError_Fragment | ErrorResult_NativeAuthStrategyError_Fragment | ErrorResult_InvalidCredentialsError_Fragment | ErrorResult_OrderStateTransitionError_Fragment | ErrorResult_EmailAddressConflictError_Fragment | ErrorResult_OrderLimitError_Fragment | ErrorResult_NegativeQuantityError_Fragment | ErrorResult_InsufficientStockError_Fragment;
+export type ErrorResultFragment = ErrorResult_MimeTypeError_Fragment | ErrorResult_LanguageNotAvailableError_Fragment | ErrorResult_ChannelDefaultLanguageError_Fragment | ErrorResult_SettlePaymentError_Fragment | ErrorResult_EmptyOrderLineSelectionError_Fragment | ErrorResult_ItemsAlreadyFulfilledError_Fragment | ErrorResult_InvalidFulfillmentHandlerError_Fragment | ErrorResult_CreateFulfillmentError_Fragment | ErrorResult_InsufficientStockOnHandError_Fragment | ErrorResult_MultipleOrderError_Fragment | ErrorResult_CancelActiveOrderError_Fragment | ErrorResult_PaymentOrderMismatchError_Fragment | ErrorResult_RefundOrderStateError_Fragment | ErrorResult_NothingToRefundError_Fragment | ErrorResult_AlreadyRefundedError_Fragment | ErrorResult_QuantityTooGreatError_Fragment | ErrorResult_RefundStateTransitionError_Fragment | ErrorResult_PaymentStateTransitionError_Fragment | ErrorResult_FulfillmentStateTransitionError_Fragment | ErrorResult_OrderModificationStateError_Fragment | ErrorResult_NoChangesSpecifiedError_Fragment | ErrorResult_PaymentMethodMissingError_Fragment | ErrorResult_RefundPaymentIdMissingError_Fragment | ErrorResult_ManualPaymentStateError_Fragment | ErrorResult_ProductOptionInUseError_Fragment | ErrorResult_MissingConditionsError_Fragment | ErrorResult_NativeAuthStrategyError_Fragment | ErrorResult_InvalidCredentialsError_Fragment | ErrorResult_OrderStateTransitionError_Fragment | ErrorResult_EmailAddressConflictError_Fragment | ErrorResult_OrderLimitError_Fragment | ErrorResult_NegativeQuantityError_Fragment | ErrorResult_InsufficientStockError_Fragment;
 
 export type ShippingMethodFragment = (
   { __typename?: 'ShippingMethod' }

+ 18 - 1
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -52,6 +52,17 @@ const result: PossibleTypesResultData = {
         ],
         SettleRefundResult: ['Refund', 'RefundStateTransitionError'],
         TransitionFulfillmentToStateResult: ['Fulfillment', 'FulfillmentStateTransitionError'],
+        ModifyOrderResult: [
+            'Order',
+            'NoChangesSpecifiedError',
+            'OrderModificationStateError',
+            'PaymentMethodMissingError',
+            'RefundPaymentIdMissingError',
+            'OrderLimitError',
+            'NegativeQuantityError',
+            'InsufficientStockError',
+        ],
+        AddManualPaymentToOrderResult: ['Order', 'ManualPaymentStateError'],
         RemoveOptionGroupFromProductResult: ['Product', 'ProductOptionInUseError'],
         CreatePromotionResult: ['Promotion', 'MissingConditionsError'],
         UpdatePromotionResult: ['Promotion', 'MissingConditionsError'],
@@ -81,10 +92,11 @@ const result: PossibleTypesResultData = {
             'Collection',
             'Customer',
             'Facet',
-            'Fulfillment',
             'HistoryEntry',
             'Job',
             'Order',
+            'Fulfillment',
+            'OrderModification',
             'PaymentMethod',
             'Product',
             'ProductVariant',
@@ -136,6 +148,11 @@ const result: PossibleTypesResultData = {
             'RefundStateTransitionError',
             'PaymentStateTransitionError',
             'FulfillmentStateTransitionError',
+            'OrderModificationStateError',
+            'NoChangesSpecifiedError',
+            'PaymentMethodMissingError',
+            'RefundPaymentIdMissingError',
+            'ManualPaymentStateError',
             'ProductOptionInUseError',
             'MissingConditionsError',
             'NativeAuthStrategyError',

+ 169 - 13
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -336,6 +336,18 @@ export type Mutation = {
     transitionOrderToState?: Maybe<TransitionOrderToStateResult>;
     transitionFulfillmentToState: TransitionFulfillmentToStateResult;
     setOrderCustomFields?: Maybe<Order>;
+    /**
+     * Allows an Order to be modified after it has been completed by the Customer. The Order must first
+     * be in the `Modifying` state.
+     */
+    modifyOrder: ModifyOrderResult;
+    /**
+     * Used to manually create a new Payment against an Order. This is used when a completed Order
+     * has been modified (using `modifyOrder`) and the price has increased. The extra payment
+     * can then be manually arranged by the administrator, and the details used to create a new
+     * Payment.
+     */
+    addManualPaymentToOrder: AddManualPaymentToOrderResult;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
     /** Create a new ProductOptionGroup */
@@ -645,6 +657,14 @@ export type MutationSetOrderCustomFieldsArgs = {
     input: UpdateOrderInput;
 };
 
+export type MutationModifyOrderArgs = {
+    input: ModifyOrderInput;
+};
+
+export type MutationAddManualPaymentToOrderArgs = {
+    input: ManualPaymentInput;
+};
+
 export type MutationUpdatePaymentMethodArgs = {
     input: UpdatePaymentMethodInput;
 };
@@ -1111,18 +1131,6 @@ export type UpdateFacetValueInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type Fulfillment = Node & {
-    nextStates: Array<Scalars['String']>;
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    orderItems: Array<OrderItem>;
-    state: Scalars['String'];
-    method: Scalars['String'];
-    trackingCode?: Maybe<Scalars['String']>;
-    customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type UpdateGlobalSettingsInput = {
     availableLanguages?: Maybe<Array<LanguageCode>>;
     trackInventory?: Maybe<Scalars['Boolean']>;
@@ -1229,6 +1237,7 @@ export type JobQueue = {
 
 export type Order = Node & {
     nextStates: Array<Scalars['String']>;
+    modifications: Array<OrderModification>;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1293,6 +1302,31 @@ export type OrderHistoryArgs = {
     options?: Maybe<HistoryEntryListOptions>;
 };
 
+export type Fulfillment = Node & {
+    nextStates: Array<Scalars['String']>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    orderItems: Array<OrderItem>;
+    state: Scalars['String'];
+    method: Scalars['String'];
+    trackingCode?: Maybe<Scalars['String']>;
+    customFields?: Maybe<Scalars['JSON']>;
+};
+
+export type OrderModification = Node & {
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    priceChange: Scalars['Int'];
+    note: Scalars['String'];
+    orderItems?: Maybe<Array<OrderItem>>;
+    surcharges?: Maybe<Array<Surcharge>>;
+    payment?: Maybe<Payment>;
+    refund?: Maybe<Refund>;
+    isSettled: Scalars['Boolean'];
+};
+
 export type UpdateOrderInput = {
     id: Scalars['ID'];
     customFields?: Maybe<Scalars['JSON']>;
@@ -1341,6 +1375,67 @@ export type UpdateOrderNoteInput = {
     isPublic?: Maybe<Scalars['Boolean']>;
 };
 
+export type AdministratorPaymentInput = {
+    paymentMethod?: Maybe<Scalars['String']>;
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
+export type AdministratorRefundInput = {
+    paymentId: Scalars['ID'];
+    reason?: Maybe<Scalars['String']>;
+};
+
+export type ModifyOrderOptions = {
+    freezePromotions?: Maybe<Scalars['Boolean']>;
+    recalculateShipping?: Maybe<Scalars['Boolean']>;
+};
+
+export type UpdateOrderAddressInput = {
+    fullName?: Maybe<Scalars['String']>;
+    company?: Maybe<Scalars['String']>;
+    streetLine1?: Maybe<Scalars['String']>;
+    streetLine2?: Maybe<Scalars['String']>;
+    city?: Maybe<Scalars['String']>;
+    province?: Maybe<Scalars['String']>;
+    postalCode?: Maybe<Scalars['String']>;
+    countryCode?: Maybe<Scalars['String']>;
+    phoneNumber?: Maybe<Scalars['String']>;
+};
+
+export type ModifyOrderInput = {
+    dryRun: Scalars['Boolean'];
+    orderId: Scalars['ID'];
+    addItems?: Maybe<Array<AddItemInput>>;
+    adjustOrderLines?: Maybe<Array<OrderLineInput>>;
+    surcharges?: Maybe<Array<SurchargeInput>>;
+    updateShippingAddress?: Maybe<UpdateOrderAddressInput>;
+    updateBillingAddress?: Maybe<UpdateOrderAddressInput>;
+    note?: Maybe<Scalars['String']>;
+    refund?: Maybe<AdministratorRefundInput>;
+    options?: Maybe<ModifyOrderOptions>;
+};
+
+export type AddItemInput = {
+    productVariantId: Scalars['ID'];
+    quantity: Scalars['Int'];
+};
+
+export type SurchargeInput = {
+    description: Scalars['String'];
+    sku?: Maybe<Scalars['String']>;
+    price: Scalars['Int'];
+    priceIncludesTax: Scalars['Boolean'];
+    taxRate?: Maybe<Scalars['Float']>;
+    taxDescription?: Maybe<Scalars['String']>;
+};
+
+export type ManualPaymentInput = {
+    orderId: Scalars['ID'];
+    method: Scalars['String'];
+    transactionId?: Maybe<Scalars['String']>;
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
 /** Returned if the Payment settlement fails */
 export type SettlePaymentError = ErrorResult & {
     errorCode: ErrorCode;
@@ -1457,6 +1552,45 @@ export type FulfillmentStateTransitionError = ErrorResult & {
     toState: Scalars['String'];
 };
 
+/** Returned when attempting to modify the contents of an Order that is not in the `Modifying` state. */
+export type OrderModificationStateError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned when a call to modifyOrder fails to specify any changes */
+export type NoChangesSpecifiedError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to modifyOrder fails to include a paymentMethod even
+ * though the price has increased as a result of the changes.
+ */
+export type PaymentMethodMissingError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to modifyOrder fails to include a refundPaymentId even
+ * though the price has decreased as a result of the changes.
+ */
+export type RefundPaymentIdMissingError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to addManualPaymentToOrder is made but the Order
+ * is not in the required state.
+ */
+export type ManualPaymentStateError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 export type SettlePaymentResult =
@@ -1497,6 +1631,18 @@ export type SettleRefundResult = Refund | RefundStateTransitionError;
 
 export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
 
+export type ModifyOrderResult =
+    | Order
+    | NoChangesSpecifiedError
+    | OrderModificationStateError
+    | PaymentMethodMissingError
+    | RefundPaymentIdMissingError
+    | OrderLimitError
+    | NegativeQuantityError
+    | InsufficientStockError;
+
+export type AddManualPaymentToOrderResult = Order | ManualPaymentStateError;
+
 export type PaymentMethodList = PaginatedList & {
     items: Array<PaymentMethod>;
     totalItems: Scalars['Int'];
@@ -2203,6 +2349,11 @@ export enum ErrorCode {
     REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
     PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
     FULFILLMENT_STATE_TRANSITION_ERROR = 'FULFILLMENT_STATE_TRANSITION_ERROR',
+    ORDER_MODIFICATION_STATE_ERROR = 'ORDER_MODIFICATION_STATE_ERROR',
+    NO_CHANGES_SPECIFIED_ERROR = 'NO_CHANGES_SPECIFIED_ERROR',
+    PAYMENT_METHOD_MISSING_ERROR = 'PAYMENT_METHOD_MISSING_ERROR',
+    REFUND_PAYMENT_ID_MISSING_ERROR = 'REFUND_PAYMENT_ID_MISSING_ERROR',
+    MANUAL_PAYMENT_STATE_ERROR = 'MANUAL_PAYMENT_STATE_ERROR',
     PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
     MISSING_CONDITIONS_ERROR = 'MISSING_CONDITIONS_ERROR',
     NATIVE_AUTH_STRATEGY_ERROR = 'NATIVE_AUTH_STRATEGY_ERROR',
@@ -3442,7 +3593,7 @@ export type OrderLine = Node & {
     discounts: Array<Adjustment>;
     taxLines: Array<TaxLine>;
     order: Order;
-    customFields?: Maybe<Scalars['JSON']>;
+    customFields?: Maybe<OrderLineCustomFields>;
 };
 
 export type Payment = Node & {
@@ -4158,6 +4309,11 @@ export type HistoryEntrySortParameter = {
     updatedAt?: Maybe<SortOrder>;
 };
 
+export type OrderLineCustomFields = {
+    test?: Maybe<Scalars['String']>;
+    test2?: Maybe<Scalars['String']>;
+};
+
 export type AuthenticationInput = {
     native?: Maybe<NativeAuthInput>;
 };

+ 15 - 2
packages/common/src/generated-shop-types.ts

@@ -181,6 +181,7 @@ export type Mutation = {
 export type MutationAddItemToOrderArgs = {
     productVariantId: Scalars['ID'];
     quantity: Scalars['Int'];
+    customFields?: Maybe<OrderLineCustomFieldsInput>;
 };
 
 export type MutationRemoveOrderLineArgs = {
@@ -189,7 +190,8 @@ export type MutationRemoveOrderLineArgs = {
 
 export type MutationAdjustOrderLineArgs = {
     orderLineId: Scalars['ID'];
-    quantity?: Maybe<Scalars['Int']>;
+    quantity: Scalars['Int'];
+    customFields?: Maybe<OrderLineCustomFieldsInput>;
 };
 
 export type MutationApplyCouponCodeArgs = {
@@ -1922,7 +1924,7 @@ export type OrderLine = Node & {
     discounts: Array<Adjustment>;
     taxLines: Array<TaxLine>;
     order: Order;
-    customFields?: Maybe<Scalars['JSON']>;
+    customFields?: Maybe<OrderLineCustomFields>;
 };
 
 export type Payment = Node & {
@@ -2719,6 +2721,17 @@ export type UpdateOrderInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type OrderLineCustomFields = {
+    __typename?: 'OrderLineCustomFields';
+    test?: Maybe<Scalars['String']>;
+    test2?: Maybe<Scalars['String']>;
+};
+
+export type OrderLineCustomFieldsInput = {
+    test?: Maybe<Scalars['String']>;
+    test2?: Maybe<Scalars['String']>;
+};
+
 export type AuthenticationInput = {
     native?: Maybe<NativeAuthInput>;
 };

+ 171 - 14
packages/common/src/generated-types.ts

@@ -378,6 +378,18 @@ export type Mutation = {
   transitionOrderToState?: Maybe<TransitionOrderToStateResult>;
   transitionFulfillmentToState: TransitionFulfillmentToStateResult;
   setOrderCustomFields?: Maybe<Order>;
+  /**
+   * Allows an Order to be modified after it has been completed by the Customer. The Order must first
+   * be in the `Modifying` state.
+   */
+  modifyOrder: ModifyOrderResult;
+  /**
+   * Used to manually create a new Payment against an Order. This is used when a completed Order
+   * has been modified (using `modifyOrder`) and the price has increased. The extra payment
+   * can then be manually arranged by the administrator, and the details used to create a new
+   * Payment.
+   */
+  addManualPaymentToOrder: AddManualPaymentToOrderResult;
   /** Update an existing PaymentMethod */
   updatePaymentMethod: PaymentMethod;
   /** Create a new ProductOptionGroup */
@@ -743,6 +755,16 @@ export type MutationSetOrderCustomFieldsArgs = {
 };
 
 
+export type MutationModifyOrderArgs = {
+  input: ModifyOrderInput;
+};
+
+
+export type MutationAddManualPaymentToOrderArgs = {
+  input: ManualPaymentInput;
+};
+
+
 export type MutationUpdatePaymentMethodArgs = {
   input: UpdatePaymentMethodInput;
 };
@@ -1256,19 +1278,6 @@ export type UpdateFacetValueInput = {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type Fulfillment = Node & {
-  __typename?: 'Fulfillment';
-  nextStates: Array<Scalars['String']>;
-  id: Scalars['ID'];
-  createdAt: Scalars['DateTime'];
-  updatedAt: Scalars['DateTime'];
-  orderItems: Array<OrderItem>;
-  state: Scalars['String'];
-  method: Scalars['String'];
-  trackingCode?: Maybe<Scalars['String']>;
-  customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type UpdateGlobalSettingsInput = {
   availableLanguages?: Maybe<Array<LanguageCode>>;
   trackInventory?: Maybe<Scalars['Boolean']>;
@@ -1386,6 +1395,7 @@ export type JobQueue = {
 export type Order = Node & {
   __typename?: 'Order';
   nextStates: Array<Scalars['String']>;
+  modifications: Array<OrderModification>;
   id: Scalars['ID'];
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
@@ -1451,6 +1461,33 @@ export type OrderHistoryArgs = {
   options?: Maybe<HistoryEntryListOptions>;
 };
 
+export type Fulfillment = Node & {
+  __typename?: 'Fulfillment';
+  nextStates: Array<Scalars['String']>;
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  orderItems: Array<OrderItem>;
+  state: Scalars['String'];
+  method: Scalars['String'];
+  trackingCode?: Maybe<Scalars['String']>;
+  customFields?: Maybe<Scalars['JSON']>;
+};
+
+export type OrderModification = Node & {
+  __typename?: 'OrderModification';
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  priceChange: Scalars['Int'];
+  note: Scalars['String'];
+  orderItems?: Maybe<Array<OrderItem>>;
+  surcharges?: Maybe<Array<Surcharge>>;
+  payment?: Maybe<Payment>;
+  refund?: Maybe<Refund>;
+  isSettled: Scalars['Boolean'];
+};
+
 export type UpdateOrderInput = {
   id: Scalars['ID'];
   customFields?: Maybe<Scalars['JSON']>;
@@ -1499,6 +1536,67 @@ export type UpdateOrderNoteInput = {
   isPublic?: Maybe<Scalars['Boolean']>;
 };
 
+export type AdministratorPaymentInput = {
+  paymentMethod?: Maybe<Scalars['String']>;
+  metadata?: Maybe<Scalars['JSON']>;
+};
+
+export type AdministratorRefundInput = {
+  paymentId: Scalars['ID'];
+  reason?: Maybe<Scalars['String']>;
+};
+
+export type ModifyOrderOptions = {
+  freezePromotions?: Maybe<Scalars['Boolean']>;
+  recalculateShipping?: Maybe<Scalars['Boolean']>;
+};
+
+export type UpdateOrderAddressInput = {
+  fullName?: Maybe<Scalars['String']>;
+  company?: Maybe<Scalars['String']>;
+  streetLine1?: Maybe<Scalars['String']>;
+  streetLine2?: Maybe<Scalars['String']>;
+  city?: Maybe<Scalars['String']>;
+  province?: Maybe<Scalars['String']>;
+  postalCode?: Maybe<Scalars['String']>;
+  countryCode?: Maybe<Scalars['String']>;
+  phoneNumber?: Maybe<Scalars['String']>;
+};
+
+export type ModifyOrderInput = {
+  dryRun: Scalars['Boolean'];
+  orderId: Scalars['ID'];
+  addItems?: Maybe<Array<AddItemInput>>;
+  adjustOrderLines?: Maybe<Array<OrderLineInput>>;
+  surcharges?: Maybe<Array<SurchargeInput>>;
+  updateShippingAddress?: Maybe<UpdateOrderAddressInput>;
+  updateBillingAddress?: Maybe<UpdateOrderAddressInput>;
+  note?: Maybe<Scalars['String']>;
+  refund?: Maybe<AdministratorRefundInput>;
+  options?: Maybe<ModifyOrderOptions>;
+};
+
+export type AddItemInput = {
+  productVariantId: Scalars['ID'];
+  quantity: Scalars['Int'];
+};
+
+export type SurchargeInput = {
+  description: Scalars['String'];
+  sku?: Maybe<Scalars['String']>;
+  price: Scalars['Int'];
+  priceIncludesTax: Scalars['Boolean'];
+  taxRate?: Maybe<Scalars['Float']>;
+  taxDescription?: Maybe<Scalars['String']>;
+};
+
+export type ManualPaymentInput = {
+  orderId: Scalars['ID'];
+  method: Scalars['String'];
+  transactionId?: Maybe<Scalars['String']>;
+  metadata?: Maybe<Scalars['JSON']>;
+};
+
 /** Returned if the Payment settlement fails */
 export type SettlePaymentError = ErrorResult & {
   __typename?: 'SettlePaymentError';
@@ -1631,6 +1729,50 @@ export type FulfillmentStateTransitionError = ErrorResult & {
   toState: Scalars['String'];
 };
 
+/** Returned when attempting to modify the contents of an Order that is not in the `Modifying` state. */
+export type OrderModificationStateError = ErrorResult & {
+  __typename?: 'OrderModificationStateError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+};
+
+/** Returned when a call to modifyOrder fails to specify any changes */
+export type NoChangesSpecifiedError = ErrorResult & {
+  __typename?: 'NoChangesSpecifiedError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to modifyOrder fails to include a paymentMethod even
+ * though the price has increased as a result of the changes.
+ */
+export type PaymentMethodMissingError = ErrorResult & {
+  __typename?: 'PaymentMethodMissingError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to modifyOrder fails to include a refundPaymentId even
+ * though the price has decreased as a result of the changes.
+ */
+export type RefundPaymentIdMissingError = ErrorResult & {
+  __typename?: 'RefundPaymentIdMissingError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to addManualPaymentToOrder is made but the Order
+ * is not in the required state.
+ */
+export type ManualPaymentStateError = ErrorResult & {
+  __typename?: 'ManualPaymentStateError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+};
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 export type SettlePaymentResult = Payment | SettlePaymentError | PaymentStateTransitionError | OrderStateTransitionError;
@@ -1645,6 +1787,10 @@ export type SettleRefundResult = Refund | RefundStateTransitionError;
 
 export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
 
+export type ModifyOrderResult = Order | NoChangesSpecifiedError | OrderModificationStateError | PaymentMethodMissingError | RefundPaymentIdMissingError | OrderLimitError | NegativeQuantityError | InsufficientStockError;
+
+export type AddManualPaymentToOrderResult = Order | ManualPaymentStateError;
+
 export type PaymentMethodList = PaginatedList & {
   __typename?: 'PaymentMethodList';
   items: Array<PaymentMethod>;
@@ -2373,6 +2519,11 @@ export enum ErrorCode {
   REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
   PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
   FULFILLMENT_STATE_TRANSITION_ERROR = 'FULFILLMENT_STATE_TRANSITION_ERROR',
+  ORDER_MODIFICATION_STATE_ERROR = 'ORDER_MODIFICATION_STATE_ERROR',
+  NO_CHANGES_SPECIFIED_ERROR = 'NO_CHANGES_SPECIFIED_ERROR',
+  PAYMENT_METHOD_MISSING_ERROR = 'PAYMENT_METHOD_MISSING_ERROR',
+  REFUND_PAYMENT_ID_MISSING_ERROR = 'REFUND_PAYMENT_ID_MISSING_ERROR',
+  MANUAL_PAYMENT_STATE_ERROR = 'MANUAL_PAYMENT_STATE_ERROR',
   PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
   MISSING_CONDITIONS_ERROR = 'MISSING_CONDITIONS_ERROR',
   NATIVE_AUTH_STRATEGY_ERROR = 'NATIVE_AUTH_STRATEGY_ERROR',
@@ -3650,7 +3801,7 @@ export type OrderLine = Node & {
   discounts: Array<Adjustment>;
   taxLines: Array<TaxLine>;
   order: Order;
-  customFields?: Maybe<Scalars['JSON']>;
+  customFields?: Maybe<OrderLineCustomFields>;
 };
 
 export type Payment = Node & {
@@ -4395,6 +4546,12 @@ export type HistoryEntrySortParameter = {
   updatedAt?: Maybe<SortOrder>;
 };
 
+export type OrderLineCustomFields = {
+  __typename?: 'OrderLineCustomFields';
+  test?: Maybe<Scalars['String']>;
+  test2?: Maybe<Scalars['String']>;
+};
+
 export type AuthenticationInput = {
   native?: Maybe<NativeAuthInput>;
 };

+ 13 - 0
packages/core/e2e/graphql/fragments.ts

@@ -372,10 +372,18 @@ export const ORDER_WITH_LINES_FRAGMENT = gql`
             }
             linePriceWithTax
         }
+        surcharges {
+            id
+            description
+            sku
+            price
+            priceWithTax
+        }
         subTotal
         subTotalWithTax
         total
         totalWithTax
+        totalQuantity
         currencyCode
         shipping
         shippingWithTax
@@ -396,6 +404,11 @@ export const ORDER_WITH_LINES_FRAGMENT = gql`
             method
             state
             metadata
+            refunds {
+                id
+                total
+                reason
+            }
         }
         total
     }

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

@@ -336,6 +336,18 @@ export type Mutation = {
     transitionOrderToState?: Maybe<TransitionOrderToStateResult>;
     transitionFulfillmentToState: TransitionFulfillmentToStateResult;
     setOrderCustomFields?: Maybe<Order>;
+    /**
+     * Allows an Order to be modified after it has been completed by the Customer. The Order must first
+     * be in the `Modifying` state.
+     */
+    modifyOrder: ModifyOrderResult;
+    /**
+     * Used to manually create a new Payment against an Order. This is used when a completed Order
+     * has been modified (using `modifyOrder`) and the price has increased. The extra payment
+     * can then be manually arranged by the administrator, and the details used to create a new
+     * Payment.
+     */
+    addManualPaymentToOrder: AddManualPaymentToOrderResult;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
     /** Create a new ProductOptionGroup */
@@ -645,6 +657,14 @@ export type MutationSetOrderCustomFieldsArgs = {
     input: UpdateOrderInput;
 };
 
+export type MutationModifyOrderArgs = {
+    input: ModifyOrderInput;
+};
+
+export type MutationAddManualPaymentToOrderArgs = {
+    input: ManualPaymentInput;
+};
+
 export type MutationUpdatePaymentMethodArgs = {
     input: UpdatePaymentMethodInput;
 };
@@ -1111,18 +1131,6 @@ export type UpdateFacetValueInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type Fulfillment = Node & {
-    nextStates: Array<Scalars['String']>;
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    orderItems: Array<OrderItem>;
-    state: Scalars['String'];
-    method: Scalars['String'];
-    trackingCode?: Maybe<Scalars['String']>;
-    customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type UpdateGlobalSettingsInput = {
     availableLanguages?: Maybe<Array<LanguageCode>>;
     trackInventory?: Maybe<Scalars['Boolean']>;
@@ -1229,6 +1237,7 @@ export type JobQueue = {
 
 export type Order = Node & {
     nextStates: Array<Scalars['String']>;
+    modifications: Array<OrderModification>;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1293,6 +1302,31 @@ export type OrderHistoryArgs = {
     options?: Maybe<HistoryEntryListOptions>;
 };
 
+export type Fulfillment = Node & {
+    nextStates: Array<Scalars['String']>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    orderItems: Array<OrderItem>;
+    state: Scalars['String'];
+    method: Scalars['String'];
+    trackingCode?: Maybe<Scalars['String']>;
+    customFields?: Maybe<Scalars['JSON']>;
+};
+
+export type OrderModification = Node & {
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    priceChange: Scalars['Int'];
+    note: Scalars['String'];
+    orderItems?: Maybe<Array<OrderItem>>;
+    surcharges?: Maybe<Array<Surcharge>>;
+    payment?: Maybe<Payment>;
+    refund?: Maybe<Refund>;
+    isSettled: Scalars['Boolean'];
+};
+
 export type UpdateOrderInput = {
     id: Scalars['ID'];
     customFields?: Maybe<Scalars['JSON']>;
@@ -1341,6 +1375,67 @@ export type UpdateOrderNoteInput = {
     isPublic?: Maybe<Scalars['Boolean']>;
 };
 
+export type AdministratorPaymentInput = {
+    paymentMethod?: Maybe<Scalars['String']>;
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
+export type AdministratorRefundInput = {
+    paymentId: Scalars['ID'];
+    reason?: Maybe<Scalars['String']>;
+};
+
+export type ModifyOrderOptions = {
+    freezePromotions?: Maybe<Scalars['Boolean']>;
+    recalculateShipping?: Maybe<Scalars['Boolean']>;
+};
+
+export type UpdateOrderAddressInput = {
+    fullName?: Maybe<Scalars['String']>;
+    company?: Maybe<Scalars['String']>;
+    streetLine1?: Maybe<Scalars['String']>;
+    streetLine2?: Maybe<Scalars['String']>;
+    city?: Maybe<Scalars['String']>;
+    province?: Maybe<Scalars['String']>;
+    postalCode?: Maybe<Scalars['String']>;
+    countryCode?: Maybe<Scalars['String']>;
+    phoneNumber?: Maybe<Scalars['String']>;
+};
+
+export type ModifyOrderInput = {
+    dryRun: Scalars['Boolean'];
+    orderId: Scalars['ID'];
+    addItems?: Maybe<Array<AddItemInput>>;
+    adjustOrderLines?: Maybe<Array<OrderLineInput>>;
+    surcharges?: Maybe<Array<SurchargeInput>>;
+    updateShippingAddress?: Maybe<UpdateOrderAddressInput>;
+    updateBillingAddress?: Maybe<UpdateOrderAddressInput>;
+    note?: Maybe<Scalars['String']>;
+    refund?: Maybe<AdministratorRefundInput>;
+    options?: Maybe<ModifyOrderOptions>;
+};
+
+export type AddItemInput = {
+    productVariantId: Scalars['ID'];
+    quantity: Scalars['Int'];
+};
+
+export type SurchargeInput = {
+    description: Scalars['String'];
+    sku?: Maybe<Scalars['String']>;
+    price: Scalars['Int'];
+    priceIncludesTax: Scalars['Boolean'];
+    taxRate?: Maybe<Scalars['Float']>;
+    taxDescription?: Maybe<Scalars['String']>;
+};
+
+export type ManualPaymentInput = {
+    orderId: Scalars['ID'];
+    method: Scalars['String'];
+    transactionId?: Maybe<Scalars['String']>;
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
 /** Returned if the Payment settlement fails */
 export type SettlePaymentError = ErrorResult & {
     errorCode: ErrorCode;
@@ -1457,6 +1552,45 @@ export type FulfillmentStateTransitionError = ErrorResult & {
     toState: Scalars['String'];
 };
 
+/** Returned when attempting to modify the contents of an Order that is not in the `Modifying` state. */
+export type OrderModificationStateError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned when a call to modifyOrder fails to specify any changes */
+export type NoChangesSpecifiedError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to modifyOrder fails to include a paymentMethod even
+ * though the price has increased as a result of the changes.
+ */
+export type PaymentMethodMissingError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to modifyOrder fails to include a refundPaymentId even
+ * though the price has decreased as a result of the changes.
+ */
+export type RefundPaymentIdMissingError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to addManualPaymentToOrder is made but the Order
+ * is not in the required state.
+ */
+export type ManualPaymentStateError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 export type SettlePaymentResult =
@@ -1497,6 +1631,18 @@ export type SettleRefundResult = Refund | RefundStateTransitionError;
 
 export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
 
+export type ModifyOrderResult =
+    | Order
+    | NoChangesSpecifiedError
+    | OrderModificationStateError
+    | PaymentMethodMissingError
+    | RefundPaymentIdMissingError
+    | OrderLimitError
+    | NegativeQuantityError
+    | InsufficientStockError;
+
+export type AddManualPaymentToOrderResult = Order | ManualPaymentStateError;
+
 export type PaymentMethodList = PaginatedList & {
     items: Array<PaymentMethod>;
     totalItems: Scalars['Int'];
@@ -2203,6 +2349,11 @@ export enum ErrorCode {
     REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
     PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
     FULFILLMENT_STATE_TRANSITION_ERROR = 'FULFILLMENT_STATE_TRANSITION_ERROR',
+    ORDER_MODIFICATION_STATE_ERROR = 'ORDER_MODIFICATION_STATE_ERROR',
+    NO_CHANGES_SPECIFIED_ERROR = 'NO_CHANGES_SPECIFIED_ERROR',
+    PAYMENT_METHOD_MISSING_ERROR = 'PAYMENT_METHOD_MISSING_ERROR',
+    REFUND_PAYMENT_ID_MISSING_ERROR = 'REFUND_PAYMENT_ID_MISSING_ERROR',
+    MANUAL_PAYMENT_STATE_ERROR = 'MANUAL_PAYMENT_STATE_ERROR',
     PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
     MISSING_CONDITIONS_ERROR = 'MISSING_CONDITIONS_ERROR',
     NATIVE_AUTH_STRATEGY_ERROR = 'NATIVE_AUTH_STRATEGY_ERROR',
@@ -3442,7 +3593,7 @@ export type OrderLine = Node & {
     discounts: Array<Adjustment>;
     taxLines: Array<TaxLine>;
     order: Order;
-    customFields?: Maybe<Scalars['JSON']>;
+    customFields?: Maybe<OrderLineCustomFields>;
 };
 
 export type Payment = Node & {
@@ -4158,6 +4309,11 @@ export type HistoryEntrySortParameter = {
     updatedAt?: Maybe<SortOrder>;
 };
 
+export type OrderLineCustomFields = {
+    test?: Maybe<Scalars['String']>;
+    test2?: Maybe<Scalars['String']>;
+};
+
 export type AuthenticationInput = {
     native?: Maybe<NativeAuthInput>;
 };
@@ -4818,6 +4974,7 @@ export type OrderWithLinesFragment = Pick<
     | 'subTotalWithTax'
     | 'total'
     | 'totalWithTax'
+    | 'totalQuantity'
     | 'currencyCode'
     | 'shipping'
     | 'shippingWithTax'
@@ -4830,10 +4987,15 @@ export type OrderWithLinesFragment = Pick<
             items: Array<OrderItemFragment>;
         }
     >;
+    surcharges: Array<Pick<Surcharge, 'id' | 'description' | 'sku' | 'price' | 'priceWithTax'>>;
     shippingLines: Array<{ shippingMethod: Pick<ShippingMethod, 'id' | 'code' | 'description'> }>;
     shippingAddress?: Maybe<ShippingAddressFragment>;
     payments?: Maybe<
-        Array<Pick<Payment, 'id' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'>>
+        Array<
+            Pick<Payment, 'id' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'> & {
+                refunds: Array<Pick<Refund, 'id' | 'total' | 'reason'>>;
+            }
+        >
     >;
 };
 
@@ -5505,6 +5667,63 @@ export type GetFulfillmentHandlersQuery = {
     >;
 };
 
+export type OrderWithModificationsFragment = Pick<Order, 'id' | 'state' | 'total' | 'totalWithTax'> & {
+    lines: Array<
+        Pick<OrderLine, 'id' | 'quantity' | 'linePrice' | 'linePriceWithTax'> & {
+            productVariant: Pick<ProductVariant, 'id' | 'name'>;
+            items: Array<Pick<OrderItem, 'id' | 'createdAt' | 'updatedAt' | 'cancelled' | 'unitPrice'>>;
+        }
+    >;
+    surcharges: Array<Pick<Surcharge, 'id' | 'description' | 'sku' | 'price' | 'priceWithTax' | 'taxRate'>>;
+    payments?: Maybe<
+        Array<
+            Pick<Payment, 'id' | 'transactionId' | 'state' | 'amount' | 'method' | 'metadata'> & {
+                refunds: Array<Pick<Refund, 'id' | 'state' | 'total' | 'paymentId'>>;
+            }
+        >
+    >;
+    modifications: Array<
+        Pick<OrderModification, 'id' | 'note' | 'priceChange' | 'isSettled'> & {
+            orderItems?: Maybe<Array<Pick<OrderItem, 'id'>>>;
+            surcharges?: Maybe<Array<Pick<Surcharge, 'id'>>>;
+            payment?: Maybe<Pick<Payment, 'id' | 'state' | 'amount' | 'method'>>;
+            refund?: Maybe<Pick<Refund, 'id' | 'state' | 'total' | 'paymentId'>>;
+        }
+    >;
+    shippingAddress?: Maybe<
+        Pick<OrderAddress, 'streetLine1' | 'city' | 'postalCode' | 'province' | 'countryCode' | 'country'>
+    >;
+    billingAddress?: Maybe<
+        Pick<OrderAddress, 'streetLine1' | 'city' | 'postalCode' | 'province' | 'countryCode' | 'country'>
+    >;
+};
+
+export type ModifyOrderMutationVariables = Exact<{
+    input: ModifyOrderInput;
+}>;
+
+export type ModifyOrderMutation = {
+    modifyOrder:
+        | OrderWithModificationsFragment
+        | Pick<NoChangesSpecifiedError, 'errorCode' | 'message'>
+        | Pick<OrderModificationStateError, 'errorCode' | 'message'>
+        | Pick<PaymentMethodMissingError, 'errorCode' | 'message'>
+        | Pick<RefundPaymentIdMissingError, 'errorCode' | 'message'>
+        | Pick<OrderLimitError, 'errorCode' | 'message'>
+        | Pick<NegativeQuantityError, 'errorCode' | 'message'>
+        | Pick<InsufficientStockError, 'errorCode' | 'message'>;
+};
+
+export type AddManualPaymentMutationVariables = Exact<{
+    input: ManualPaymentInput;
+}>;
+
+export type AddManualPaymentMutation = {
+    addManualPaymentToOrder:
+        | OrderWithModificationsFragment
+        | Pick<ManualPaymentStateError, 'errorCode' | 'message'>;
+};
+
 export type DeletePromotionAdHoc1MutationVariables = Exact<{ [key: string]: never }>;
 
 export type DeletePromotionAdHoc1Mutation = { deletePromotion: Pick<DeletionResponse, 'result'> };
@@ -6727,12 +6946,16 @@ export namespace OrderWithLines {
     export type Items = NonNullable<
         NonNullable<NonNullable<NonNullable<OrderWithLinesFragment['lines']>[number]>['items']>[number]
     >;
+    export type Surcharges = NonNullable<NonNullable<OrderWithLinesFragment['surcharges']>[number]>;
     export type ShippingLines = NonNullable<NonNullable<OrderWithLinesFragment['shippingLines']>[number]>;
     export type ShippingMethod = NonNullable<
         NonNullable<NonNullable<OrderWithLinesFragment['shippingLines']>[number]>['shippingMethod']
     >;
     export type ShippingAddress = NonNullable<OrderWithLinesFragment['shippingAddress']>;
     export type Payments = NonNullable<NonNullable<OrderWithLinesFragment['payments']>[number]>;
+    export type Refunds = NonNullable<
+        NonNullable<NonNullable<NonNullable<OrderWithLinesFragment['payments']>[number]>['refunds']>[number]
+    >;
 }
 
 export namespace Promotion {
@@ -7422,6 +7645,67 @@ export namespace GetFulfillmentHandlers {
     >;
 }
 
+export namespace OrderWithModifications {
+    export type Fragment = OrderWithModificationsFragment;
+    export type Lines = NonNullable<NonNullable<OrderWithModificationsFragment['lines']>[number]>;
+    export type ProductVariant = NonNullable<
+        NonNullable<NonNullable<OrderWithModificationsFragment['lines']>[number]>['productVariant']
+    >;
+    export type Items = NonNullable<
+        NonNullable<
+            NonNullable<NonNullable<OrderWithModificationsFragment['lines']>[number]>['items']
+        >[number]
+    >;
+    export type Surcharges = NonNullable<NonNullable<OrderWithModificationsFragment['surcharges']>[number]>;
+    export type Payments = NonNullable<NonNullable<OrderWithModificationsFragment['payments']>[number]>;
+    export type Refunds = NonNullable<
+        NonNullable<
+            NonNullable<NonNullable<OrderWithModificationsFragment['payments']>[number]>['refunds']
+        >[number]
+    >;
+    export type Modifications = NonNullable<
+        NonNullable<OrderWithModificationsFragment['modifications']>[number]
+    >;
+    export type OrderItems = NonNullable<
+        NonNullable<
+            NonNullable<NonNullable<OrderWithModificationsFragment['modifications']>[number]>['orderItems']
+        >[number]
+    >;
+    export type _Surcharges = NonNullable<
+        NonNullable<
+            NonNullable<NonNullable<OrderWithModificationsFragment['modifications']>[number]>['surcharges']
+        >[number]
+    >;
+    export type Payment = NonNullable<
+        NonNullable<NonNullable<OrderWithModificationsFragment['modifications']>[number]>['payment']
+    >;
+    export type Refund = NonNullable<
+        NonNullable<NonNullable<OrderWithModificationsFragment['modifications']>[number]>['refund']
+    >;
+    export type ShippingAddress = NonNullable<OrderWithModificationsFragment['shippingAddress']>;
+    export type BillingAddress = NonNullable<OrderWithModificationsFragment['billingAddress']>;
+}
+
+export namespace ModifyOrder {
+    export type Variables = ModifyOrderMutationVariables;
+    export type Mutation = ModifyOrderMutation;
+    export type ModifyOrder = NonNullable<ModifyOrderMutation['modifyOrder']>;
+    export type ErrorResultInlineFragment = DiscriminateUnion<
+        NonNullable<ModifyOrderMutation['modifyOrder']>,
+        { __typename?: 'ErrorResult' }
+    >;
+}
+
+export namespace AddManualPayment {
+    export type Variables = AddManualPaymentMutationVariables;
+    export type Mutation = AddManualPaymentMutation;
+    export type AddManualPaymentToOrder = NonNullable<AddManualPaymentMutation['addManualPaymentToOrder']>;
+    export type ErrorResultInlineFragment = DiscriminateUnion<
+        NonNullable<AddManualPaymentMutation['addManualPaymentToOrder']>,
+        { __typename?: 'ErrorResult' }
+    >;
+}
+
 export namespace DeletePromotionAdHoc1 {
     export type Variables = DeletePromotionAdHoc1MutationVariables;
     export type Mutation = DeletePromotionAdHoc1Mutation;

+ 23 - 4
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -179,6 +179,7 @@ export type Mutation = {
 export type MutationAddItemToOrderArgs = {
     productVariantId: Scalars['ID'];
     quantity: Scalars['Int'];
+    customFields?: Maybe<OrderLineCustomFieldsInput>;
 };
 
 export type MutationRemoveOrderLineArgs = {
@@ -187,7 +188,8 @@ export type MutationRemoveOrderLineArgs = {
 
 export type MutationAdjustOrderLineArgs = {
     orderLineId: Scalars['ID'];
-    quantity?: Maybe<Scalars['Int']>;
+    quantity: Scalars['Int'];
+    customFields?: Maybe<OrderLineCustomFieldsInput>;
 };
 
 export type MutationApplyCouponCodeArgs = {
@@ -1864,7 +1866,7 @@ export type OrderLine = Node & {
     discounts: Array<Adjustment>;
     taxLines: Array<TaxLine>;
     order: Order;
-    customFields?: Maybe<Scalars['JSON']>;
+    customFields?: Maybe<OrderLineCustomFields>;
 };
 
 export type Payment = Node & {
@@ -2610,6 +2612,16 @@ export type UpdateOrderInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type OrderLineCustomFields = {
+    test?: Maybe<Scalars['String']>;
+    test2?: Maybe<Scalars['String']>;
+};
+
+export type OrderLineCustomFieldsInput = {
+    test?: Maybe<Scalars['String']>;
+    test2?: Maybe<Scalars['String']>;
+};
+
 export type AuthenticationInput = {
     native?: Maybe<NativeAuthInput>;
 };
@@ -2635,9 +2647,13 @@ export type TestOrderFragmentFragment = Pick<
 > & {
     discounts: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
     lines: Array<
-        Pick<OrderLine, 'id' | 'quantity' | 'linePrice' | 'linePriceWithTax'> & {
+        Pick<
+            OrderLine,
+            'id' | 'quantity' | 'linePrice' | 'linePriceWithTax' | 'unitPrice' | 'unitPriceWithTax'
+        > & {
             productVariant: Pick<ProductVariant, 'id'>;
             discounts: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
+            items: Array<Pick<OrderItem, 'id'>>;
         }
     >;
     shippingLines: Array<{ shippingMethod: Pick<ShippingMethod, 'id' | 'code' | 'description'> }>;
@@ -3093,6 +3109,9 @@ export namespace TestOrderFragment {
     export type _Discounts = NonNullable<
         NonNullable<NonNullable<NonNullable<TestOrderFragmentFragment['lines']>[number]>['discounts']>[number]
     >;
+    export type Items = NonNullable<
+        NonNullable<NonNullable<NonNullable<TestOrderFragmentFragment['lines']>[number]>['items']>[number]
+    >;
     export type ShippingLines = NonNullable<NonNullable<TestOrderFragmentFragment['shippingLines']>[number]>;
     export type ShippingMethod = NonNullable<
         NonNullable<NonNullable<TestOrderFragmentFragment['shippingLines']>[number]>['shippingMethod']
@@ -3100,7 +3119,7 @@ export namespace TestOrderFragment {
     export type Customer = NonNullable<TestOrderFragmentFragment['customer']>;
     export type User = NonNullable<NonNullable<TestOrderFragmentFragment['customer']>['user']>;
     export type History = NonNullable<TestOrderFragmentFragment['history']>;
-    export type Items = NonNullable<
+    export type _Items = NonNullable<
         NonNullable<NonNullable<TestOrderFragmentFragment['history']>['items']>[number]
     >;
 }

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

@@ -24,6 +24,8 @@ export const TEST_ORDER_FRAGMENT = gql`
             quantity
             linePrice
             linePriceWithTax
+            unitPrice
+            unitPriceWithTax
             productVariant {
                 id
             }
@@ -33,6 +35,9 @@ export const TEST_ORDER_FRAGMENT = gql`
                 description
                 type
             }
+            items {
+                id
+            }
         }
         shippingLines {
             shippingMethod {

+ 2 - 2
packages/core/e2e/localization.e2e-spec.ts

@@ -4,7 +4,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import {
     GetProductWithVariants,
@@ -15,7 +15,7 @@ import {
 import { GET_PRODUCT_WITH_VARIANTS, UPDATE_PRODUCT } from './graphql/shared-definitions';
 
 /* tslint:disable:no-non-null-assertion */
-describe('Role resolver', () => {
+describe('Localization', () => {
     const { server, adminClient } = createTestEnvironment(testConfig);
 
     beforeAll(async () => {

+ 1180 - 0
packages/core/e2e/order-modification.e2e-spec.ts

@@ -0,0 +1,1180 @@
+/* tslint:disable:no-non-null-assertion */
+import { omit } from '@vendure/common/lib/omit';
+import { pick } from '@vendure/common/lib/pick';
+import {
+    DefaultLogger,
+    defaultShippingCalculator,
+    defaultShippingEligibilityChecker,
+    mergeConfig,
+    ShippingCalculator,
+} from '@vendure/core';
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
+import gql from 'graphql-tag';
+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 { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
+import {
+    AddManualPayment,
+    AdminTransition,
+    CreateShippingMethod,
+    ErrorCode,
+    GetOrder,
+    GlobalFlag,
+    LanguageCode,
+    ModifyOrder,
+    OrderFragment,
+    OrderWithLinesFragment,
+    OrderWithModificationsFragment,
+    UpdateProductVariants,
+} from './graphql/generated-e2e-admin-types';
+import {
+    AddItemToOrder,
+    AddItemToOrderMutationVariables,
+    SetShippingAddress,
+    SetShippingMethod,
+    TestOrderWithPaymentsFragment,
+    TransitionToState,
+    UpdatedOrderFragment,
+} from './graphql/generated-e2e-shop-types';
+import {
+    ADMIN_TRANSITION_TO_STATE,
+    CREATE_SHIPPING_METHOD,
+    GET_ORDER,
+    UPDATE_PRODUCT_VARIANTS,
+} from './graphql/shared-definitions';
+import {
+    ADD_ITEM_TO_ORDER,
+    SET_SHIPPING_ADDRESS,
+    SET_SHIPPING_METHOD,
+    TRANSITION_TO_STATE,
+} from './graphql/shop-definitions';
+import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
+
+const SHIPPING_GB = 500;
+const SHIPPING_US = 1000;
+const SHIPPING_OTHER = 750;
+const testCalculator = new ShippingCalculator({
+    code: 'test-calculator',
+    description: [{ languageCode: LanguageCode.en, value: 'Has metadata' }],
+    args: {},
+    calculate: (ctx, order, args) => {
+        let price;
+        switch (order.shippingAddress.countryCode) {
+            case 'GB':
+                price = SHIPPING_GB;
+                break;
+            case 'US':
+                price = SHIPPING_US;
+                break;
+            default:
+                price = SHIPPING_OTHER;
+        }
+        return {
+            price,
+            priceIncludesTax: true,
+            taxRate: 20,
+        };
+    },
+});
+
+describe('Order modification', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            paymentOptions: {
+                paymentMethodHandlers: [testSuccessfulPaymentMethod],
+            },
+            shippingOptions: {
+                shippingCalculators: [defaultShippingCalculator, testCalculator],
+            },
+        }),
+    );
+
+    let orderId: string;
+    let testShippingMethodId: string;
+    const orderGuard: ErrorResultGuard<
+        UpdatedOrderFragment | OrderWithModificationsFragment | OrderFragment
+    > = createErrorResultGuard(input => !!input.id);
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 2,
+        });
+        await adminClient.asSuperAdmin();
+
+        await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+            UPDATE_PRODUCT_VARIANTS,
+            {
+                input: [
+                    {
+                        id: 'T_1',
+                        trackInventory: GlobalFlag.TRUE,
+                    },
+                    {
+                        id: 'T_2',
+                        trackInventory: GlobalFlag.TRUE,
+                    },
+                    {
+                        id: 'T_3',
+                        trackInventory: GlobalFlag.TRUE,
+                    },
+                ],
+            },
+        );
+
+        const { createShippingMethod } = await adminClient.query<
+            CreateShippingMethod.Mutation,
+            CreateShippingMethod.Variables
+        >(CREATE_SHIPPING_METHOD, {
+            input: {
+                code: 'new-method',
+                fulfillmentHandler: manualFulfillmentHandler.code,
+                checker: {
+                    code: defaultShippingEligibilityChecker.code,
+                    arguments: [
+                        {
+                            name: 'orderMinimum',
+                            value: '0',
+                        },
+                    ],
+                },
+                calculator: {
+                    code: testCalculator.code,
+                    arguments: [],
+                },
+                translations: [{ languageCode: LanguageCode.en, name: 'test method', description: '' }],
+            },
+        });
+        testShippingMethodId = createShippingMethod.id;
+
+        // create an order and check out
+        await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_1',
+            quantity: 1,
+        });
+        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_4',
+            quantity: 2,
+        });
+        await proceedToArrangingPayment(shopClient);
+        const result = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+        orderGuard.assertSuccess(result);
+        orderId = result.id;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('modifyOrder returns error result when not in Modifying state', async () => {
+        const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+            id: orderId,
+        });
+        const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+            MODIFY_ORDER,
+            {
+                input: {
+                    dryRun: false,
+                    orderId,
+                    adjustOrderLines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 3 })),
+                },
+            },
+        );
+
+        orderGuard.assertErrorResult(modifyOrder);
+        expect(modifyOrder.errorCode).toBe(ErrorCode.ORDER_MODIFICATION_STATE_ERROR);
+    });
+
+    it('transition to Modifying state', async () => {
+        const { transitionOrderToState } = await adminClient.query<
+            AdminTransition.Mutation,
+            AdminTransition.Variables
+        >(ADMIN_TRANSITION_TO_STATE, {
+            id: orderId,
+            state: 'Modifying',
+        });
+        orderGuard.assertSuccess(transitionOrderToState);
+
+        expect(transitionOrderToState.state).toBe('Modifying');
+    });
+
+    describe('error cases', () => {
+        it('no changes specified error', async () => {
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId,
+                    },
+                },
+            );
+
+            orderGuard.assertErrorResult(modifyOrder);
+
+            expect(modifyOrder.errorCode).toBe(ErrorCode.NO_CHANGES_SPECIFIED_ERROR);
+        });
+
+        it('no refund paymentId specified', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId,
+                        surcharges: [{ price: -500, priceIncludesTax: true, description: 'Discount' }],
+                    },
+                },
+            );
+            orderGuard.assertErrorResult(modifyOrder);
+
+            expect(modifyOrder.errorCode).toBe(ErrorCode.REFUND_PAYMENT_ID_MISSING_ERROR);
+            await assertOrderIsUnchanged(order!);
+        });
+
+        it('addItems negative quantity', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId,
+                        addItems: [{ productVariantId: 'T_3', quantity: -1 }],
+                    },
+                },
+            );
+            orderGuard.assertErrorResult(modifyOrder);
+
+            expect(modifyOrder.errorCode).toBe(ErrorCode.NEGATIVE_QUANTITY_ERROR);
+            await assertOrderIsUnchanged(order!);
+        });
+
+        it('adjustOrderLines negative quantity', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId,
+                        adjustOrderLines: [{ orderLineId: order!.lines[0].id, quantity: -1 }],
+                    },
+                },
+            );
+            orderGuard.assertErrorResult(modifyOrder);
+
+            expect(modifyOrder.errorCode).toBe(ErrorCode.NEGATIVE_QUANTITY_ERROR);
+            await assertOrderIsUnchanged(order!);
+        });
+
+        it('addItems insufficient stock', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId,
+                        addItems: [{ productVariantId: 'T_3', quantity: 500 }],
+                    },
+                },
+            );
+            orderGuard.assertErrorResult(modifyOrder);
+
+            expect(modifyOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
+            await assertOrderIsUnchanged(order!);
+        });
+
+        it('adjustOrderLines insufficient stock', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId,
+                        adjustOrderLines: [{ orderLineId: order!.lines[0].id, quantity: 500 }],
+                    },
+                },
+            );
+            orderGuard.assertErrorResult(modifyOrder);
+
+            expect(modifyOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
+            await assertOrderIsUnchanged(order!);
+        });
+
+        it('addItems order limit', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId,
+                        addItems: [{ productVariantId: 'T_4', quantity: 9999 }],
+                    },
+                },
+            );
+            orderGuard.assertErrorResult(modifyOrder);
+
+            expect(modifyOrder.errorCode).toBe(ErrorCode.ORDER_LIMIT_ERROR);
+            await assertOrderIsUnchanged(order!);
+        });
+
+        it('adjustOrderLines order limit', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId,
+                        adjustOrderLines: [{ orderLineId: order!.lines[1].id, quantity: 9999 }],
+                    },
+                },
+            );
+            orderGuard.assertErrorResult(modifyOrder);
+
+            expect(modifyOrder.errorCode).toBe(ErrorCode.ORDER_LIMIT_ERROR);
+            await assertOrderIsUnchanged(order!);
+        });
+    });
+
+    describe('dry run', () => {
+        it('addItems', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: true,
+                        orderId,
+                        addItems: [{ productVariantId: 'T_5', quantity: 1 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const expectedTotal = order!.totalWithTax + Math.round(14374 * 1.2); // price of variant T_5
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            expect(modifyOrder.lines.length).toBe(order!.lines.length + 1);
+            await assertOrderIsUnchanged(order!);
+        });
+
+        it('adjustOrderLines up', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: true,
+                        orderId,
+                        adjustOrderLines: [{ orderLineId: order!.lines[0].id, quantity: 3 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const expectedTotal = order!.totalWithTax + order!.lines[0].unitPriceWithTax * 2;
+            expect(modifyOrder.lines[0].items.length).toBe(3);
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            await assertOrderIsUnchanged(order!);
+        });
+
+        it('adjustOrderLines down', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: true,
+                        orderId,
+                        adjustOrderLines: [{ orderLineId: order!.lines[1].id, quantity: 1 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const expectedTotal = order!.totalWithTax - order!.lines[1].unitPriceWithTax;
+            expect(modifyOrder.lines[1].items.filter(i => i.cancelled).length).toBe(1);
+            expect(modifyOrder.lines[1].items.filter(i => !i.cancelled).length).toBe(1);
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            await assertOrderIsUnchanged(order!);
+        });
+
+        it('adjustOrderLines to zero', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: true,
+                        orderId,
+                        adjustOrderLines: [{ orderLineId: order!.lines[0].id, quantity: 0 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const expectedTotal = order!.totalWithTax - order!.lines[0].linePriceWithTax;
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            expect(modifyOrder.lines[0].items.every(i => i.cancelled)).toBe(true);
+            await assertOrderIsUnchanged(order!);
+        });
+
+        it('surcharge positive', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: true,
+                        orderId,
+                        surcharges: [
+                            {
+                                description: 'extra fee',
+                                sku: '123',
+                                price: 300,
+                                priceIncludesTax: true,
+                                taxRate: 20,
+                                taxDescription: 'VAT',
+                            },
+                        ],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const expectedTotal = order!.totalWithTax + 300;
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            expect(modifyOrder.surcharges.map(s => omit(s, ['id']))).toEqual([
+                {
+                    description: 'extra fee',
+                    sku: '123',
+                    price: 250,
+                    priceWithTax: 300,
+                    taxRate: 20,
+                },
+            ]);
+            await assertOrderIsUnchanged(order!);
+        });
+
+        it('surcharge negative', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: true,
+                        orderId,
+                        surcharges: [
+                            {
+                                description: 'special discount',
+                                sku: '123',
+                                price: -300,
+                                priceIncludesTax: true,
+                                taxRate: 20,
+                                taxDescription: 'VAT',
+                            },
+                        ],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const expectedTotal = order!.totalWithTax + -300;
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            expect(modifyOrder.surcharges.map(s => omit(s, ['id']))).toEqual([
+                {
+                    description: 'special discount',
+                    sku: '123',
+                    price: -250,
+                    priceWithTax: -300,
+                    taxRate: 20,
+                },
+            ]);
+            await assertOrderIsUnchanged(order!);
+        });
+    });
+
+    describe('wet run', () => {
+        async function assertModifiedOrderIsPersisted(order: OrderWithModificationsFragment) {
+            const { order: order2 } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: order.id,
+            });
+            expect(order2!.totalWithTax).toBe(order!.totalWithTax);
+            expect(order2!.lines.length).toBe(order!.lines.length);
+            expect(order2!.surcharges.length).toBe(order!.surcharges.length);
+            expect(order2!.payments!.length).toBe(order!.payments!.length);
+            expect(order2!.payments!.map(p => pick(p, ['id', 'amount', 'method']))).toEqual(
+                order!.payments!.map(p => pick(p, ['id', 'amount', 'method'])),
+            );
+        }
+
+        it('addItems', async () => {
+            const order = await createOrderAndTransitionToModifyingState([
+                {
+                    productVariantId: 'T_1',
+                    quantity: 1,
+                },
+            ]);
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order.id,
+                        addItems: [{ productVariantId: 'T_5', quantity: 1 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const priceDelta = Math.round(14374 * 1.2); // price of variant T_5
+            const expectedTotal = order!.totalWithTax + priceDelta;
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            expect(modifyOrder.lines.length).toBe(order!.lines.length + 1);
+            expect(modifyOrder.modifications.length).toBe(1);
+            expect(modifyOrder.modifications[0].priceChange).toBe(priceDelta);
+            expect(modifyOrder.modifications[0].orderItems?.length).toBe(1);
+            expect(modifyOrder.modifications[0].orderItems?.map(i => i.id)).toEqual([
+                modifyOrder.lines[1].items[0].id,
+            ]);
+
+            await assertModifiedOrderIsPersisted(modifyOrder);
+        });
+
+        it('adjustOrderLines up', async () => {
+            const order = await createOrderAndTransitionToModifyingState([
+                {
+                    productVariantId: 'T_1',
+                    quantity: 1,
+                },
+            ]);
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order.id,
+                        adjustOrderLines: [{ orderLineId: order!.lines[0].id, quantity: 2 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const priceDelta = order!.lines[0].unitPriceWithTax;
+            const expectedTotal = order!.totalWithTax + priceDelta;
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            expect(modifyOrder.lines[0].quantity).toBe(2);
+            expect(modifyOrder.modifications.length).toBe(1);
+            expect(modifyOrder.modifications[0].priceChange).toBe(priceDelta);
+            expect(modifyOrder.modifications[0].orderItems?.length).toBe(1);
+            expect(
+                modifyOrder.lines[0].items
+                    .map(i => i.id)
+                    .includes(modifyOrder.modifications?.[0].orderItems?.[0].id as string),
+            ).toBe(true);
+            await assertModifiedOrderIsPersisted(modifyOrder);
+        });
+
+        it('adjustOrderLines down', async () => {
+            const order = await createOrderAndTransitionToModifyingState([
+                {
+                    productVariantId: 'T_1',
+                    quantity: 2,
+                },
+            ]);
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order.id,
+                        adjustOrderLines: [{ orderLineId: order!.lines[0].id, quantity: 1 }],
+                        refund: { paymentId: order!.payments![0].id },
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const priceDelta = -order!.lines[0].unitPriceWithTax;
+            const expectedTotal = order!.totalWithTax + priceDelta;
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            expect(modifyOrder.lines[0].quantity).toBe(1);
+            expect(modifyOrder.payments?.length).toBe(1);
+            expect(modifyOrder.payments?.[0].refunds.length).toBe(1);
+            expect(modifyOrder.payments?.[0].refunds[0]).toEqual({
+                id: 'T_1',
+                state: 'Pending',
+                total: -priceDelta,
+                paymentId: modifyOrder.payments?.[0].id,
+            });
+            expect(modifyOrder.modifications.length).toBe(1);
+            expect(modifyOrder.modifications[0].priceChange).toBe(priceDelta);
+            expect(modifyOrder.modifications[0].surcharges).toEqual(modifyOrder.surcharges.map(pick(['id'])));
+            expect(modifyOrder.modifications[0].orderItems?.length).toBe(1);
+            expect(
+                modifyOrder.lines[0].items
+                    .map(i => i.id)
+                    .includes(modifyOrder.modifications?.[0].orderItems?.[0].id as string),
+            ).toBe(true);
+            await assertModifiedOrderIsPersisted(modifyOrder);
+        });
+
+        it('surcharge positive', async () => {
+            const order = await createOrderAndTransitionToModifyingState([
+                {
+                    productVariantId: 'T_1',
+                    quantity: 1,
+                },
+            ]);
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order.id,
+                        surcharges: [
+                            {
+                                description: 'extra fee',
+                                sku: '123',
+                                price: 300,
+                                priceIncludesTax: true,
+                                taxRate: 20,
+                                taxDescription: 'VAT',
+                            },
+                        ],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const priceDelta = 300;
+            const expectedTotal = order!.totalWithTax + priceDelta;
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            expect(modifyOrder.surcharges.map(s => omit(s, ['id']))).toEqual([
+                {
+                    description: 'extra fee',
+                    sku: '123',
+                    price: 250,
+                    priceWithTax: 300,
+                    taxRate: 20,
+                },
+            ]);
+            expect(modifyOrder.modifications.length).toBe(1);
+            expect(modifyOrder.modifications[0].priceChange).toBe(priceDelta);
+            expect(modifyOrder.modifications[0].surcharges).toEqual(modifyOrder.surcharges.map(pick(['id'])));
+            await assertModifiedOrderIsPersisted(modifyOrder);
+        });
+
+        it('surcharge negative', async () => {
+            const order = await createOrderAndTransitionToModifyingState([
+                {
+                    productVariantId: 'T_1',
+                    quantity: 1,
+                },
+            ]);
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order!.id,
+                        surcharges: [
+                            {
+                                description: 'special discount',
+                                sku: '123',
+                                price: -300,
+                                priceIncludesTax: true,
+                                taxRate: 20,
+                                taxDescription: 'VAT',
+                            },
+                        ],
+                        refund: {
+                            paymentId: order!.payments![0].id,
+                        },
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const expectedTotal = order!.totalWithTax + -300;
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            expect(modifyOrder.surcharges.map(s => omit(s, ['id']))).toEqual([
+                {
+                    description: 'special discount',
+                    sku: '123',
+                    price: -250,
+                    priceWithTax: -300,
+                    taxRate: 20,
+                },
+            ]);
+            expect(modifyOrder.modifications.length).toBe(1);
+            expect(modifyOrder.modifications[0].priceChange).toBe(-300);
+            await assertModifiedOrderIsPersisted(modifyOrder);
+        });
+
+        it('update updateShippingAddress, recalculate shipping', async () => {
+            const order = await createOrderAndTransitionToModifyingState([
+                {
+                    productVariantId: 'T_1',
+                    quantity: 1,
+                },
+            ]);
+
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order!.id,
+                        updateShippingAddress: {
+                            countryCode: 'US',
+                        },
+                        options: {
+                            recalculateShipping: true,
+                        },
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const priceDelta = SHIPPING_US - SHIPPING_OTHER;
+            const expectedTotal = order!.totalWithTax + priceDelta;
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            expect(modifyOrder.shippingAddress?.countryCode).toBe('US');
+            expect(modifyOrder.modifications.length).toBe(1);
+            expect(modifyOrder.modifications[0].priceChange).toBe(priceDelta);
+            await assertModifiedOrderIsPersisted(modifyOrder);
+        });
+
+        it('update updateShippingAddress, do not recalculate shipping', async () => {
+            const order = await createOrderAndTransitionToModifyingState([
+                {
+                    productVariantId: 'T_1',
+                    quantity: 1,
+                },
+            ]);
+
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order!.id,
+                        updateShippingAddress: {
+                            countryCode: 'US',
+                        },
+                        options: {
+                            recalculateShipping: false,
+                        },
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            const priceDelta = 0;
+            const expectedTotal = order!.totalWithTax + priceDelta;
+            expect(modifyOrder.totalWithTax).toBe(expectedTotal);
+            expect(modifyOrder.shippingAddress?.countryCode).toBe('US');
+            expect(modifyOrder.modifications.length).toBe(1);
+            expect(modifyOrder.modifications[0].priceChange).toBe(priceDelta);
+            await assertModifiedOrderIsPersisted(modifyOrder);
+        });
+    });
+
+    describe('additional payment handling', () => {
+        let orderId2: string;
+
+        beforeAll(async () => {
+            const order = await createOrderAndTransitionToModifyingState([
+                {
+                    productVariantId: 'T_1',
+                    quantity: 1,
+                },
+            ]);
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order.id,
+                        surcharges: [
+                            {
+                                description: 'extra fee',
+                                sku: '123',
+                                price: 300,
+                                priceIncludesTax: true,
+                                taxRate: 20,
+                                taxDescription: 'VAT',
+                            },
+                        ],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+            orderId2 = modifyOrder.id;
+        });
+
+        it('cannot transition back to original state if no payment is set', async () => {
+            const { transitionOrderToState } = await adminClient.query<
+                AdminTransition.Mutation,
+                AdminTransition.Variables
+            >(ADMIN_TRANSITION_TO_STATE, {
+                id: orderId2,
+                state: 'PaymentSettled',
+            });
+            orderGuard.assertErrorResult(transitionOrderToState);
+            expect(transitionOrderToState!.errorCode).toBe(ErrorCode.ORDER_STATE_TRANSITION_ERROR);
+            expect(transitionOrderToState!.transitionError).toBe(
+                `Can only transition to the "ArrangingAdditionalPayment" state`,
+            );
+        });
+
+        it('can transition to ArrangingAdditionalPayment state', async () => {
+            const { transitionOrderToState } = await adminClient.query<
+                AdminTransition.Mutation,
+                AdminTransition.Variables
+            >(ADMIN_TRANSITION_TO_STATE, {
+                id: orderId2,
+                state: 'ArrangingAdditionalPayment',
+            });
+            orderGuard.assertSuccess(transitionOrderToState);
+            expect(transitionOrderToState!.state).toBe('ArrangingAdditionalPayment');
+        });
+
+        it('cannot transition from ArrangingAdditionalPayment when total not covered by Payments', async () => {
+            const { transitionOrderToState } = await adminClient.query<
+                AdminTransition.Mutation,
+                AdminTransition.Variables
+            >(ADMIN_TRANSITION_TO_STATE, {
+                id: orderId2,
+                state: 'PaymentSettled',
+            });
+            orderGuard.assertErrorResult(transitionOrderToState);
+            expect(transitionOrderToState!.errorCode).toBe(ErrorCode.ORDER_STATE_TRANSITION_ERROR);
+            expect(transitionOrderToState!.transitionError).toBe(
+                `Cannot transition away from "ArrangingAdditionalPayment" unless Order total is covered by Payments`,
+            );
+        });
+
+        it('addManualPaymentToOrder', async () => {
+            const { addManualPaymentToOrder } = await adminClient.query<
+                AddManualPayment.Mutation,
+                AddManualPayment.Variables
+            >(ADD_MANUAL_PAYMENT, {
+                input: {
+                    orderId: orderId2,
+                    method: 'test',
+                    transactionId: 'ABC123',
+                    metadata: {
+                        foo: 'bar',
+                    },
+                },
+            });
+            orderGuard.assertSuccess(addManualPaymentToOrder);
+
+            expect(addManualPaymentToOrder.payments?.length).toBe(2);
+            expect(addManualPaymentToOrder.payments![1]).toEqual({
+                id: 'T_10',
+                transactionId: 'ABC123',
+                state: 'Settled',
+                amount: 300,
+                method: 'test',
+                metadata: {
+                    foo: 'bar',
+                },
+                refunds: [],
+            });
+        });
+
+        it('transition back to original state', async () => {
+            const { transitionOrderToState } = await adminClient.query<
+                AdminTransition.Mutation,
+                AdminTransition.Variables
+            >(ADMIN_TRANSITION_TO_STATE, {
+                id: orderId2,
+                state: 'PaymentSettled',
+            });
+            orderGuard.assertSuccess(transitionOrderToState);
+
+            expect(transitionOrderToState.state).toBe('PaymentSettled');
+        });
+    });
+
+    describe('refund handling', () => {
+        let orderId3: string;
+
+        beforeAll(async () => {
+            const order = await createOrderAndTransitionToModifyingState([
+                {
+                    productVariantId: 'T_1',
+                    quantity: 1,
+                },
+            ]);
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: order.id,
+                        surcharges: [
+                            {
+                                description: 'discount',
+                                sku: '123',
+                                price: -300,
+                                priceIncludesTax: true,
+                                taxRate: 20,
+                                taxDescription: 'VAT',
+                            },
+                        ],
+                        refund: {
+                            paymentId: order.payments![0].id,
+                            reason: 'discount',
+                        },
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+            orderId3 = modifyOrder.id;
+        });
+
+        it('cannot transition to ArrangingAdditionalPayment state if no payment is needed', async () => {
+            const { transitionOrderToState } = await adminClient.query<
+                AdminTransition.Mutation,
+                AdminTransition.Variables
+            >(ADMIN_TRANSITION_TO_STATE, {
+                id: orderId3,
+                state: 'ArrangingAdditionalPayment',
+            });
+            orderGuard.assertErrorResult(transitionOrderToState);
+            expect(transitionOrderToState!.errorCode).toBe(ErrorCode.ORDER_STATE_TRANSITION_ERROR);
+            expect(transitionOrderToState!.transitionError).toBe(
+                `Cannot transition Order to the \"ArrangingAdditionalPayment\" state as no additional payments are needed`,
+            );
+        });
+
+        it('can transition to original state', async () => {
+            const { transitionOrderToState } = await adminClient.query<
+                AdminTransition.Mutation,
+                AdminTransition.Variables
+            >(ADMIN_TRANSITION_TO_STATE, {
+                id: orderId3,
+                state: 'PaymentSettled',
+            });
+            orderGuard.assertSuccess(transitionOrderToState);
+            expect(transitionOrderToState!.state).toBe('PaymentSettled');
+
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId3,
+            });
+            expect(order?.payments![0].refunds.length).toBe(1);
+            expect(order?.payments![0].refunds[0].total).toBe(300);
+            expect(order?.payments![0].refunds[0].reason).toBe('discount');
+        });
+    });
+
+    async function assertOrderIsUnchanged(order: OrderWithLinesFragment) {
+        const { order: order2 } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+            id: order.id,
+        });
+        expect(order2!.totalWithTax).toBe(order!.totalWithTax);
+        expect(order2!.lines.length).toBe(order!.lines.length);
+        expect(order2!.surcharges.length).toBe(order!.surcharges.length);
+        expect(order2!.totalQuantity).toBe(order!.totalQuantity);
+    }
+
+    async function createOrderAndTransitionToModifyingState(
+        items: AddItemToOrderMutationVariables[],
+    ): Promise<TestOrderWithPaymentsFragment> {
+        await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+        for (const itemInput of items) {
+            await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
+                ADD_ITEM_TO_ORDER,
+                itemInput,
+            );
+        }
+
+        await shopClient.query<SetShippingAddress.Mutation, SetShippingAddress.Variables>(
+            SET_SHIPPING_ADDRESS,
+            {
+                input: {
+                    fullName: 'name',
+                    streetLine1: '12 the street',
+                    city: 'foo',
+                    postalCode: '123456',
+                    countryCode: 'AT',
+                },
+            },
+        );
+
+        await shopClient.query<SetShippingMethod.Mutation, SetShippingMethod.Variables>(SET_SHIPPING_METHOD, {
+            id: testShippingMethodId,
+        });
+
+        await shopClient.query<TransitionToState.Mutation, TransitionToState.Variables>(TRANSITION_TO_STATE, {
+            state: 'ArrangingPayment',
+        });
+
+        const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+        orderGuard.assertSuccess(order);
+
+        const { transitionOrderToState } = await adminClient.query<
+            AdminTransition.Mutation,
+            AdminTransition.Variables
+        >(ADMIN_TRANSITION_TO_STATE, {
+            id: order.id,
+            state: 'Modifying',
+        });
+        return order;
+    }
+});
+
+export const ORDER_WITH_MODIFICATION_FRAGMENT = gql`
+    fragment OrderWithModifications on Order {
+        id
+        state
+        total
+        totalWithTax
+        lines {
+            id
+            quantity
+            linePrice
+            linePriceWithTax
+            productVariant {
+                id
+                name
+            }
+            items {
+                id
+                createdAt
+                updatedAt
+                cancelled
+                unitPrice
+            }
+        }
+        surcharges {
+            id
+            description
+            sku
+            price
+            priceWithTax
+            taxRate
+        }
+        payments {
+            id
+            transactionId
+            state
+            amount
+            method
+            metadata
+            refunds {
+                id
+                state
+                total
+                paymentId
+            }
+        }
+        modifications {
+            id
+            note
+            priceChange
+            isSettled
+            orderItems {
+                id
+            }
+            surcharges {
+                id
+            }
+            payment {
+                id
+                state
+                amount
+                method
+            }
+            refund {
+                id
+                state
+                total
+                paymentId
+            }
+        }
+        shippingAddress {
+            streetLine1
+            city
+            postalCode
+            province
+            countryCode
+            country
+        }
+        billingAddress {
+            streetLine1
+            city
+            postalCode
+            province
+            countryCode
+            country
+        }
+    }
+`;
+
+export const MODIFY_ORDER = gql`
+    mutation ModifyOrder($input: ModifyOrderInput!) {
+        modifyOrder(input: $input) {
+            ...OrderWithModifications
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+    ${ORDER_WITH_MODIFICATION_FRAGMENT}
+`;
+
+export const ADD_MANUAL_PAYMENT = gql`
+    mutation AddManualPayment($input: ManualPaymentInput!) {
+        addManualPaymentToOrder(input: $input) {
+            ...OrderWithModifications
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+    ${ORDER_WITH_MODIFICATION_FRAGMENT}
+`;

+ 19 - 0
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -3,9 +3,11 @@ import {
     AddFulfillmentToOrderResult,
     CancelOrderResult,
     MutationAddFulfillmentToOrderArgs,
+    MutationAddManualPaymentToOrderArgs,
     MutationAddNoteToOrderArgs,
     MutationCancelOrderArgs,
     MutationDeleteOrderNoteArgs,
+    MutationModifyOrderArgs,
     MutationRefundOrderArgs,
     MutationSetOrderCustomFieldsArgs,
     MutationSettlePaymentArgs,
@@ -145,4 +147,21 @@ export class OrderResolver {
     ) {
         return this.orderService.transitionFulfillmentToState(ctx, args.id, args.state as FulfillmentState);
     }
+
+    @Transaction('manual')
+    @Mutation()
+    @Allow(Permission.UpdateOrder)
+    async modifyOrder(@Ctx() ctx: RequestContext, @Args() args: MutationModifyOrderArgs) {
+        return this.orderService.modifyOrder(ctx, args.input);
+    }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdateOrder)
+    async addManualPaymentToOrder(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationAddManualPaymentToOrderArgs,
+    ) {
+        return this.orderService.addManualPaymentToOrder(ctx, args.input);
+    }
 }

+ 18 - 0
packages/core/src/api/resolvers/entity/order-entity.resolver.ts

@@ -1,6 +1,7 @@
 import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql';
 import { HistoryEntryListOptions, OrderHistoryArgs, SortOrder } from '@vendure/common/lib/generated-types';
 
+import { assertFound } from '../../../common/utils';
 import { Order } from '../../../entity/order/order.entity';
 import { ProductOptionGroup } from '../../../entity/product-option-group/product-option-group.entity';
 import { HistoryService } from '../../../service/services/history.service';
@@ -40,6 +41,15 @@ export class OrderEntityResolver {
         return this.orderService.getOrderSurcharges(ctx, order.id);
     }
 
+    @ResolveField()
+    async lines(@Ctx() ctx: RequestContext, @Parent() order: Order) {
+        if (order.lines) {
+            return order.lines;
+        }
+        const { lines } = await assertFound(this.orderService.findOne(ctx, order.id));
+        return lines;
+    }
+
     @ResolveField()
     async history(
         @Ctx() ctx: RequestContext,
@@ -68,6 +78,14 @@ export class OrderEntityResolver {
 export class OrderAdminEntityResolver {
     constructor(private orderService: OrderService) {}
 
+    @ResolveField()
+    async modifications(@Ctx() ctx: RequestContext, @Parent() order: Order) {
+        if (order.modifications) {
+            return order.modifications;
+        }
+        return this.orderService.getOrderModifications(ctx, order.id);
+    }
+
     @ResolveField()
     async nextStates(@Parent() order: Order) {
         return this.orderService.getNextOrderStates(order);

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

@@ -1,7 +1,21 @@
 type Order {
     nextStates: [String!]!
+    modifications: [OrderModification!]!
 }
 
 type Fulfillment {
     nextStates: [String!]!
 }
+
+type OrderModification implements Node {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+    priceChange: Int!
+    note: String!
+    orderItems: [OrderItem!]
+    surcharges: [Surcharge!]
+    payment: Payment
+    refund: Refund
+    isSettled: Boolean!
+}

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

@@ -15,6 +15,22 @@ type Mutation {
     transitionOrderToState(id: ID!, state: String!): TransitionOrderToStateResult
     transitionFulfillmentToState(id: ID!, state: String!): TransitionFulfillmentToStateResult!
     setOrderCustomFields(input: UpdateOrderInput!): Order
+    """
+    Allows an Order to be modified after it has been completed by the Customer. The Order must first
+    be in the `Modifying` state.
+    """
+    modifyOrder(input: ModifyOrderInput!): ModifyOrderResult!
+    """
+    Used to manually create a new Payment against an Order. This is used when a completed Order
+    has been modified (using `modifyOrder`) and the price has increased. The extra payment
+    can then be manually arranged by the administrator, and the details used to create a new
+    Payment.
+    """
+    addManualPaymentToOrder(input: ManualPaymentInput!): AddManualPaymentToOrderResult!
+}
+
+type Order {
+    nextStates: [String!]!
 }
 
 # generated by generateListOptions function
@@ -68,6 +84,72 @@ input UpdateOrderNoteInput {
     isPublic: Boolean
 }
 
+input AdministratorPaymentInput {
+    paymentMethod: String
+    metadata: JSON
+}
+
+input AdministratorRefundInput {
+    paymentId: ID!
+    reason: String
+}
+
+input ModifyOrderOptions {
+    freezePromotions: Boolean
+    recalculateShipping: Boolean
+}
+
+input UpdateOrderAddressInput {
+    fullName: String
+    company: String
+    streetLine1: String
+    streetLine2: String
+    city: String
+    province: String
+    postalCode: String
+    countryCode: String
+    phoneNumber: String
+}
+
+input ModifyOrderInput {
+    dryRun: Boolean!
+    orderId: ID!
+    addItems: [AddItemInput!]
+    adjustOrderLines: [OrderLineInput!]
+    surcharges: [SurchargeInput!]
+    updateShippingAddress: UpdateOrderAddressInput
+    updateBillingAddress: UpdateOrderAddressInput
+    note: String
+    refund: AdministratorRefundInput
+    options: ModifyOrderOptions
+}
+
+input AddItemInput {
+    productVariantId: ID!
+    quantity: Int!
+}
+
+input OrderLineInput {
+    orderLineId: ID!
+    quantity: Int!
+}
+
+input SurchargeInput {
+    description: String!
+    sku: String
+    price: Int!
+    priceIncludesTax: Boolean!
+    taxRate: Float
+    taxDescription: String
+}
+
+input ManualPaymentInput {
+    orderId: ID!
+    method: String!
+    transactionId: String
+    metadata: JSON
+}
+
 "Returned if the Payment settlement fails"
 type SettlePaymentError implements ErrorResult {
     errorCode: ErrorCode!
@@ -184,6 +266,45 @@ type FulfillmentStateTransitionError implements ErrorResult {
     toState: String!
 }
 
+"Returned when attempting to modify the contents of an Order that is not in the `Modifying` state."
+type OrderModificationStateError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+}
+
+"Returned when a call to modifyOrder fails to specify any changes"
+type NoChangesSpecifiedError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+}
+
+"""
+Returned when a call to modifyOrder fails to include a paymentMethod even
+though the price has increased as a result of the changes.
+"""
+type PaymentMethodMissingError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+}
+
+"""
+Returned when a call to modifyOrder fails to include a refundPaymentId even
+though the price has decreased as a result of the changes.
+"""
+type RefundPaymentIdMissingError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+}
+
+"""
+Returned when a call to addManualPaymentToOrder is made but the Order
+is not in the required state.
+"""
+type ManualPaymentStateError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+}
+
 union TransitionOrderToStateResult = Order | OrderStateTransitionError
 union SettlePaymentResult =
       Payment
@@ -217,3 +338,13 @@ union RefundOrderResult =
     | RefundStateTransitionError
 union SettleRefundResult = Refund | RefundStateTransitionError
 union TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError
+union ModifyOrderResult =
+      Order
+    | NoChangesSpecifiedError
+    | OrderModificationStateError
+    | PaymentMethodMissingError
+    | RefundPaymentIdMissingError
+    | OrderLimitError
+    | NegativeQuantityError
+    | InsufficientStockError
+union AddManualPaymentToOrderResult = Order | ManualPaymentStateError

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

@@ -230,6 +230,56 @@ export class FulfillmentStateTransitionError extends ErrorResult {
   }
 }
 
+export class OrderModificationStateError extends ErrorResult {
+  readonly __typename = 'OrderModificationStateError';
+  readonly errorCode = 'ORDER_MODIFICATION_STATE_ERROR' as any;
+  readonly message = 'ORDER_MODIFICATION_STATE_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class NoChangesSpecifiedError extends ErrorResult {
+  readonly __typename = 'NoChangesSpecifiedError';
+  readonly errorCode = 'NO_CHANGES_SPECIFIED_ERROR' as any;
+  readonly message = 'NO_CHANGES_SPECIFIED_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class PaymentMethodMissingError extends ErrorResult {
+  readonly __typename = 'PaymentMethodMissingError';
+  readonly errorCode = 'PAYMENT_METHOD_MISSING_ERROR' as any;
+  readonly message = 'PAYMENT_METHOD_MISSING_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class RefundPaymentIdMissingError extends ErrorResult {
+  readonly __typename = 'RefundPaymentIdMissingError';
+  readonly errorCode = 'REFUND_PAYMENT_ID_MISSING_ERROR' as any;
+  readonly message = 'REFUND_PAYMENT_ID_MISSING_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class ManualPaymentStateError extends ErrorResult {
+  readonly __typename = 'ManualPaymentStateError';
+  readonly errorCode = 'MANUAL_PAYMENT_STATE_ERROR' as any;
+  readonly message = 'MANUAL_PAYMENT_STATE_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
 export class ProductOptionInUseError extends ErrorResult {
   readonly __typename = 'ProductOptionInUseError';
   readonly errorCode = 'PRODUCT_OPTION_IN_USE_ERROR' as any;
@@ -330,7 +380,7 @@ export class InsufficientStockError extends ErrorResult {
 }
 
 
-const errorTypeNames = new Set(['MimeTypeError', 'LanguageNotAvailableError', 'ChannelDefaultLanguageError', 'SettlePaymentError', 'EmptyOrderLineSelectionError', 'ItemsAlreadyFulfilledError', 'InvalidFulfillmentHandlerError', 'CreateFulfillmentError', 'InsufficientStockOnHandError', 'MultipleOrderError', 'CancelActiveOrderError', 'PaymentOrderMismatchError', 'RefundOrderStateError', 'NothingToRefundError', 'AlreadyRefundedError', 'QuantityTooGreatError', 'RefundStateTransitionError', 'PaymentStateTransitionError', 'FulfillmentStateTransitionError', 'ProductOptionInUseError', 'MissingConditionsError', 'NativeAuthStrategyError', 'InvalidCredentialsError', 'OrderStateTransitionError', 'EmailAddressConflictError', 'OrderLimitError', 'NegativeQuantityError', 'InsufficientStockError']);
+const errorTypeNames = new Set(['MimeTypeError', 'LanguageNotAvailableError', 'ChannelDefaultLanguageError', 'SettlePaymentError', 'EmptyOrderLineSelectionError', 'ItemsAlreadyFulfilledError', 'InvalidFulfillmentHandlerError', 'CreateFulfillmentError', 'InsufficientStockOnHandError', 'MultipleOrderError', 'CancelActiveOrderError', 'PaymentOrderMismatchError', 'RefundOrderStateError', 'NothingToRefundError', 'AlreadyRefundedError', 'QuantityTooGreatError', 'RefundStateTransitionError', 'PaymentStateTransitionError', 'FulfillmentStateTransitionError', 'OrderModificationStateError', 'NoChangesSpecifiedError', 'PaymentMethodMissingError', 'RefundPaymentIdMissingError', 'ManualPaymentStateError', 'ProductOptionInUseError', 'MissingConditionsError', 'NativeAuthStrategyError', 'InvalidCredentialsError', 'OrderStateTransitionError', 'EmailAddressConflictError', 'OrderLimitError', 'NegativeQuantityError', 'InsufficientStockError']);
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
 }
@@ -411,6 +461,16 @@ export const adminErrorOperationTypeResolvers = {
       return isGraphQLError(value) ? (value as any).__typename : 'Fulfillment';
     },
   },
+  ModifyOrderResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Order';
+    },
+  },
+  AddManualPaymentToOrderResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Order';
+    },
+  },
   RemoveOptionGroupFromProductResult: {
     __resolveType(value: any) {
       return isGraphQLError(value) ? (value as any).__typename : 'Product';

+ 1 - 1
packages/core/src/common/utils.ts

@@ -18,7 +18,7 @@ export function foundIn<T>(set: T[], compareBy: keyof T) {
 }
 
 /**
- * Indentity function which asserts to the type system that a promise which can resolve to T or undefined
+ * Identity function which asserts to the type system that a promise which can resolve to T or undefined
  * does in fact resolve to T.
  * Used when performing a "find" operation on an entity which we are sure exists, as in the case that we
  * just successfully created or updated it.

+ 2 - 0
packages/core/src/entity/entities.ts

@@ -23,6 +23,7 @@ import { HistoryEntry } from './history-entry/history-entry.entity';
 import { OrderHistoryEntry } from './history-entry/order-history-entry.entity';
 import { OrderItem } from './order-item/order-item.entity';
 import { OrderLine } from './order-line/order-line.entity';
+import { OrderModification } from './order-modification/order-modification.entity';
 import { Order } from './order/order.entity';
 import { PaymentMethod } from './payment-method/payment-method.entity';
 import { Payment } from './payment/payment.entity';
@@ -92,6 +93,7 @@ export const coreEntitiesMap = {
     OrderHistoryEntry,
     OrderItem,
     OrderLine,
+    OrderModification,
     Payment,
     PaymentMethod,
     Product,

+ 62 - 0
packages/core/src/entity/order-modification/order-modification.entity.ts

@@ -0,0 +1,62 @@
+import { Adjustment, OrderAddress } from '@vendure/common/lib/generated-types';
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { Column, Entity, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, OneToOne } from 'typeorm';
+
+import { Calculated } from '../../common/calculated-decorator';
+import { VendureEntity } from '../base/base.entity';
+import { OrderItem } from '../order-item/order-item.entity';
+import { Order } from '../order/order.entity';
+import { Payment } from '../payment/payment.entity';
+import { Refund } from '../refund/refund.entity';
+import { Cancellation } from '../stock-movement/cancellation.entity';
+import { Surcharge } from '../surcharge/surcharge.entity';
+
+/**
+ * @description
+ * An entity which represents a modification to an order which has been placed, and
+ * then modified afterwards by an administrator.
+ *
+ * @docsCategory entities
+ */
+@Entity()
+export class OrderModification extends VendureEntity {
+    constructor(input?: DeepPartial<OrderModification>) {
+        super(input);
+    }
+
+    @Column()
+    note: string;
+
+    @ManyToOne(type => Order, order => order.modifications, { onDelete: 'CASCADE' })
+    order: Order;
+
+    @ManyToMany(type => OrderItem)
+    @JoinTable()
+    orderItems: OrderItem[];
+
+    @OneToMany(type => Surcharge, surcharge => surcharge.orderModification)
+    surcharges: Surcharge[];
+
+    @Column()
+    priceChange: number;
+
+    @OneToOne(type => Payment)
+    @JoinColumn()
+    payment?: Payment;
+
+    @OneToOne(type => Refund)
+    @JoinColumn()
+    refund?: Refund;
+
+    @Column('simple-json', { nullable: true }) shippingAddressChange: OrderAddress;
+
+    @Column('simple-json', { nullable: true }) billingAddressChange: OrderAddress;
+
+    @Calculated()
+    get isSettled(): boolean {
+        if (this.priceChange === 0) {
+            return true;
+        }
+        return !!this.payment || !!this.refund;
+    }
+}

+ 4 - 0
packages/core/src/entity/order/order.entity.ts

@@ -21,6 +21,7 @@ import { Customer } from '../customer/customer.entity';
 import { EntityId } from '../entity-id.decorator';
 import { OrderItem } from '../order-item/order-item.entity';
 import { OrderLine } from '../order-line/order-line.entity';
+import { OrderModification } from '../order-modification/order-modification.entity';
 import { Payment } from '../payment/payment.entity';
 import { Promotion } from '../promotion/promotion.entity';
 import { ShippingLine } from '../shipping-line/shipping-line.entity';
@@ -90,6 +91,9 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
     @JoinTable()
     channels: Channel[];
 
+    @OneToMany(type => OrderModification, modification => modification.order)
+    modifications: OrderModification[];
+
     @Column()
     subTotal: number;
 

+ 4 - 0
packages/core/src/entity/surcharge/surcharge.entity.ts

@@ -6,6 +6,7 @@ import { Column, Entity, ManyToOne } from 'typeorm';
 import { Calculated } from '../../common/calculated-decorator';
 import { grossPriceOf, netPriceOf } from '../../common/tax-utils';
 import { VendureEntity } from '../base/base.entity';
+import { OrderModification } from '../order-modification/order-modification.entity';
 import { Order } from '../order/order.entity';
 
 /**
@@ -39,6 +40,9 @@ export class Surcharge extends VendureEntity {
     @ManyToOne(type => Order, order => order.surcharges, { onDelete: 'CASCADE' })
     order: Order;
 
+    @ManyToOne(type => OrderModification, orderModification => orderModification.surcharges)
+    orderModification: OrderModification;
+
     @Calculated()
     get price(): number {
         return this.listPriceIncludesTax ? netPriceOf(this.listPrice, this.taxRate) : this.listPrice;

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

@@ -83,7 +83,9 @@
   "message": {
     "asset-to-be-deleted-is-featured": "The selected {assetCount, plural, one {Asset is} other {Assets are}} featured by {products, plural, =0 {} one {1 Product} other {# Products}} {variants, plural, =0 {} one { 1 ProductVariant} other { # ProductVariants}} {collections, plural, =0 {} one { 1 Collection} other { # Collections}}",
     "cannot-remove-tax-category-due-to-tax-rates": "Cannot remove TaxCategory \"{ name }\" as it is referenced by {count, plural, one {1 TaxRate} other {# TaxRates}}",
+    "cannot-transition-from-arranging-additional-payment": "Cannot transition away from \"ArrangingAdditionalPayment\" unless Order total is covered by Payments",
     "cannot-transition-order-from-to": "Cannot transition Order from \"{ fromState }\" to \"{ toState }\"",
+    "cannot-transition-no-additional-payments-needed": "Cannot transition Order to the \"ArrangingAdditionalPayment\" state as no additional payments are needed",
     "cannot-transition-to-shipping-when-order-is-empty": "Cannot transition Order to the \"ArrangingShipping\" state when it is empty",
     "cannot-transition-to-payment-without-customer": "Cannot transition Order to the \"ArrangingPayment\" state without Customer details",
     "cannot-transition-unless-all-cancelled": "Cannot transition Order to the \"Cancelled\" state unless all OrderItems are cancelled",
@@ -92,6 +94,7 @@
     "cannot-transition-unless-some-order-items-shipped": "Cannot transition Order to the \"PartiallyShipped\" state unless some OrderItems are shipped",
     "cannot-transition-unless-all-order-items-shipped": "Cannot transition Order to the \"Shipped\" state unless all OrderItems are shipped",
     "cannot-transition-without-authorized-payments": "Cannot transition Order to the \"PaymentAuthorized\" state when the total is not covered by authorized Payments",
+    "cannot-transition-without-modification-payment": "Can only transition to the \"ArrangingAdditionalPayment\" state",
     "cannot-transition-without-settled-payments": "Cannot transition Order to the \"PaymentSettled\" state when the total is not covered by settled Payments",
     "country-used-in-addresses": "The selected Country cannot be deleted as it is used in {count, plural, one {1 Address} other {# Addresses}}",
     "facet-force-deleted": "The Facet was deleted and its FacetValues were removed from {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}",

+ 7 - 7
packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -301,7 +301,7 @@ describe('OrderCalculator', () => {
                             },
                         ],
                     });
-                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
+                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], [order.lines[0]]);
 
                     expect(order.subTotal).toBe(4167);
                     expect(order.subTotalWithTax).toBe(5000);
@@ -328,7 +328,7 @@ describe('OrderCalculator', () => {
                             },
                         ],
                     });
-                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
+                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], [order.lines[0]]);
 
                     expect(order.subTotal).toBe(1173);
                     expect(order.subTotalWithTax).toBe(1350);
@@ -484,7 +484,7 @@ describe('OrderCalculator', () => {
                         }),
                     );
 
-                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
+                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], [order.lines[0]]);
 
                     expect(order.subTotalWithTax).toBe(504);
                     // Now the fixedPriceOrderAction should be in effect
@@ -523,7 +523,7 @@ describe('OrderCalculator', () => {
                         }),
                     );
 
-                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
+                    await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], [order.lines[0]]);
 
                     expect(order.subTotalWithTax).toBe(420);
                     // Now the fixedPriceOrderAction should be in effect
@@ -778,7 +778,7 @@ describe('OrderCalculator', () => {
                     ctx,
                     order,
                     [buy3Get50pcOffOrder, spend1000Get10pcOffOrder],
-                    order.lines[0],
+                    [order.lines[0]],
                 );
 
                 expect(order.discounts.length).toBe(1);
@@ -828,7 +828,7 @@ describe('OrderCalculator', () => {
                         ctx,
                         order,
                         [buy3Get50pcOffOrder, spend1000Get10pcOffOrder],
-                        order.lines[0],
+                        [order.lines[0]],
                     );
 
                     expect(order.subTotal).toBe(1080);
@@ -854,7 +854,7 @@ describe('OrderCalculator', () => {
                         ctx,
                         order,
                         [buy3Get50pcOffOrder, spend1000Get10pcOffOrder],
-                        order.lines[0],
+                        [order.lines[0]],
                     );
 
                     expect(order.subTotal).toBe(900);

+ 7 - 4
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -38,7 +38,8 @@ export class OrderCalculator {
         ctx: RequestContext,
         order: Order,
         promotions: Promotion[],
-        updatedOrderLine?: OrderLine,
+        updatedOrderLines: OrderLine[] = [],
+        options?: { recalculateShipping?: boolean },
     ): Promise<OrderItem[]> {
         const { taxZoneStrategy } = this.configService.taxOptions;
         const zones = this.zoneService.findAll(ctx);
@@ -52,7 +53,7 @@ export class OrderCalculator {
             taxZoneChanged = true;
         }
         const updatedOrderItems = new Set<OrderItem>();
-        if (updatedOrderLine) {
+        for (const updatedOrderLine of updatedOrderLines) {
             await this.applyTaxesToOrderLine(
                 ctx,
                 order,
@@ -79,8 +80,10 @@ export class OrderCalculator {
                 // altered the unit prices, which in turn will alter the tax payable.
                 await this.applyTaxes(ctx, order, activeTaxZone);
             }
-            await this.applyShipping(ctx, order);
-            await this.applyShippingPromotions(ctx, order, promotions);
+            if (options?.recalculateShipping !== false) {
+                await this.applyShipping(ctx, order);
+                await this.applyShippingPromotions(ctx, order, promotions);
+            }
         }
         this.calculateOrderTotals(order);
         return taxZoneChanged ? order.getOrderItems() : Array.from(updatedOrderItems);

+ 23 - 0
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -0,0 +1,23 @@
+import { Injectable } from '@nestjs/common';
+import { ModifyOrderInput } from '@vendure/common/lib/generated-types';
+
+import { RequestContext } from '../../../api/common/request-context';
+import { Order } from '../../../entity/order/order.entity';
+import { Promotion } from '../../../entity/promotion/promotion.entity';
+import { TransactionalConnection } from '../../transaction/transactional-connection';
+import { OrderCalculator } from '../order-calculator/order-calculator';
+
+@Injectable()
+export class OrderModifier {
+    constructor(private connection: TransactionalConnection, private orderCalculator: OrderCalculator) {}
+
+    noChangesSpecified(input: ModifyOrderInput): boolean {
+        const noChanges =
+            !input.adjustOrderLines?.length &&
+            !input.addItems?.length &&
+            !input.surcharges?.length &&
+            !input.updateShippingAddress &&
+            !input.updateBillingAddress;
+        return noChanges;
+    }
+}

+ 30 - 0
packages/core/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -10,7 +10,9 @@ import { StateMachineConfig, Transitions } from '../../../common/finite-state-ma
 import { validateTransitionDefinition } from '../../../common/finite-state-machine/validate-transition-definition';
 import { awaitPromiseOrObservable } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
+import { OrderModification } from '../../../entity/order-modification/order-modification.entity';
 import { Order } from '../../../entity/order/order.entity';
+import { Payment } from '../../../entity/payment/payment.entity';
 import { HistoryService } from '../../services/history.service';
 import { PromotionService } from '../../services/promotion.service';
 import { StockMovementService } from '../../services/stock-movement.service';
@@ -22,6 +24,7 @@ import {
     orderItemsArePartiallyShipped,
     orderItemsAreShipped,
     orderTotalIsCovered,
+    totalCoveredByPayments,
 } from '../utils/order-utils';
 
 import { OrderState, orderStateTransitions, OrderTransitionData } from './order-state';
@@ -70,6 +73,33 @@ export class OrderStateMachine {
      * Specific business logic to be executed on Order state transitions.
      */
     private async onTransitionStart(fromState: OrderState, toState: OrderState, data: OrderTransitionData) {
+        if (fromState === 'Modifying') {
+            const modifications = await this.connection
+                .getRepository(data.ctx, OrderModification)
+                .find({ where: { order: data.order }, relations: ['refund', 'payment'] });
+            if (toState === 'ArrangingAdditionalPayment') {
+                if (modifications.every(modification => modification.isSettled)) {
+                    return `message.cannot-transition-no-additional-payments-needed`;
+                }
+            } else {
+                if (modifications.some(modification => !modification.isSettled)) {
+                    return `message.cannot-transition-without-modification-payment`;
+                }
+            }
+        }
+        if (fromState === 'ArrangingAdditionalPayment') {
+            const existingPayments = await this.connection.getRepository(data.ctx, Payment).find({
+                relations: ['refunds'],
+                where: {
+                    order: { id: data.order.id },
+                },
+            });
+            data.order.payments = existingPayments;
+            const deficit = data.order.totalWithTax - totalCoveredByPayments(data.order);
+            if (0 < deficit) {
+                return `message.cannot-transition-from-arranging-additional-payment`;
+            }
+        }
         if (toState === 'ArrangingPayment') {
             if (data.order.lines.length === 0) {
                 return `message.cannot-transition-to-payment-when-order-is-empty`;

+ 20 - 5
packages/core/src/service/helpers/order-state-machine/order-state.ts

@@ -19,6 +19,8 @@ export type OrderState =
     | 'Shipped'
     | 'PartiallyDelivered'
     | 'Delivered'
+    | 'Modifying'
+    | 'ArrangingAdditionalPayment'
     | 'Cancelled';
 
 export const orderStateTransitions: Transitions<OrderState> = {
@@ -32,23 +34,36 @@ export const orderStateTransitions: Transitions<OrderState> = {
         to: ['PaymentAuthorized', 'PaymentSettled', 'AddingItems', 'Cancelled'],
     },
     PaymentAuthorized: {
-        to: ['PaymentSettled', 'Cancelled'],
+        to: ['PaymentSettled', 'Cancelled', 'Modifying'],
     },
     PaymentSettled: {
-        to: ['PartiallyDelivered', 'Delivered', 'PartiallyShipped', 'Shipped', 'Cancelled'],
+        to: ['PartiallyDelivered', 'Delivered', 'PartiallyShipped', 'Shipped', 'Cancelled', 'Modifying'],
     },
     PartiallyShipped: {
-        to: ['Shipped', 'PartiallyDelivered', 'Cancelled'],
+        to: ['Shipped', 'PartiallyDelivered', 'Cancelled', 'Modifying'],
     },
     Shipped: {
-        to: ['PartiallyDelivered', 'Delivered', 'Cancelled'],
+        to: ['PartiallyDelivered', 'Delivered', 'Cancelled', 'Modifying'],
     },
     PartiallyDelivered: {
-        to: ['Delivered', 'Cancelled'],
+        to: ['Delivered', 'Cancelled', 'Modifying'],
     },
     Delivered: {
         to: ['Cancelled'],
     },
+    Modifying: {
+        to: [
+            'PaymentAuthorized',
+            'PaymentSettled',
+            'PartiallyShipped',
+            'Shipped',
+            'PartiallyDelivered',
+            'ArrangingAdditionalPayment',
+        ],
+    },
+    ArrangingAdditionalPayment: {
+        to: ['PaymentAuthorized', 'PaymentSettled', 'PartiallyShipped', 'Shipped', 'PartiallyDelivered'],
+    },
     Cancelled: {
         to: [],
     },

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

@@ -1,3 +1,5 @@
+import { summate } from '@vendure/common/lib/shared-utils';
+
 import { OrderItem } from '../../../entity/order-item/order-item.entity';
 import { Order } from '../../../entity/order/order.entity';
 import { PaymentState } from '../payment-state-machine/payment-state';
@@ -6,10 +8,23 @@ import { PaymentState } from '../payment-state-machine/payment-state';
  * Returns true if the Order total is covered by Payments in the specified state.
  */
 export function orderTotalIsCovered(order: Order, state: PaymentState): boolean {
-    return (
-        order.payments.filter(p => p.state === state).reduce((sum, p) => sum + p.amount, 0) ===
-        order.totalWithTax
-    );
+    const paymentsTotal = totalCoveredByPayments(order, state);
+    return paymentsTotal === order.totalWithTax;
+}
+
+/**
+ * Returns the total amount covered by all Payments (minus any refunds)
+ */
+export function totalCoveredByPayments(order: Order, state?: PaymentState): number {
+    const payments = state
+        ? order.payments.filter(p => p.state === state)
+        : order.payments.filter(p => p.state !== 'Error' && p.state !== 'Declined');
+    let total = 0;
+    for (const payment of payments) {
+        const refundTotal = summate(payment.refunds, 'total');
+        total += payment.amount - Math.abs(refundTotal);
+    }
+    return total;
 }
 
 /**

+ 2 - 0
packages/core/src/service/index.ts

@@ -5,6 +5,8 @@ export * from './helpers/utils/get-entity-or-throw';
 export * from './helpers/list-query-builder/list-query-builder';
 export * from './helpers/external-authentication/external-authentication.service';
 export * from './helpers/order-calculator/order-calculator';
+export * from './helpers/order-merger/order-merger';
+export * from './helpers/order-modifier/order-modifier';
 export * from './helpers/order-state-machine/order-state';
 export * from './helpers/fulfillment-state-machine/fulfillment-state';
 export * from './helpers/payment-state-machine/payment-state';

+ 2 - 0
packages/core/src/service/service.module.ts

@@ -16,6 +16,7 @@ import { FulfillmentStateMachine } from './helpers/fulfillment-state-machine/ful
 import { ListQueryBuilder } from './helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from './helpers/order-calculator/order-calculator';
 import { OrderMerger } from './helpers/order-merger/order-merger';
+import { OrderModifier } from './helpers/order-modifier/order-modifier';
 import { OrderStateMachine } from './helpers/order-state-machine/order-state-machine';
 import { PasswordCiper } from './helpers/password-cipher/password-ciper';
 import { PaymentStateMachine } from './helpers/payment-state-machine/payment-state-machine';
@@ -98,6 +99,7 @@ const helpers = [
     OrderStateMachine,
     FulfillmentStateMachine,
     OrderMerger,
+    OrderModifier,
     PaymentStateMachine,
     ListQueryBuilder,
     ShippingCalculator,

+ 1 - 0
packages/core/src/service/services/order-testing.service.ts

@@ -108,6 +108,7 @@ export class OrderTestingService {
         const mockOrder = new Order({
             lines: [],
             surcharges: [],
+            modifications: [],
         });
         mockOrder.shippingAddress = shippingAddress;
         for (const line of lines) {

+ 258 - 8
packages/core/src/service/services/order.service.ts

@@ -9,6 +9,7 @@ import {
 } from '@vendure/common/lib/generated-shop-types';
 import {
     AddFulfillmentToOrderResult,
+    AddManualPaymentToOrderResult,
     AddNoteToOrderInput,
     CancelOrderInput,
     CancelOrderResult,
@@ -17,6 +18,9 @@ import {
     DeletionResult,
     FulfillOrderInput,
     HistoryEntryType,
+    ManualPaymentInput,
+    ModifyOrderInput,
+    ModifyOrderResult,
     OrderLineInput,
     OrderProcessState,
     RefundOrderInput,
@@ -32,7 +36,7 @@ import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../api/common/request-context';
 import { ErrorResultUnion, isGraphQlErrorResult } from '../../common/error/error-result';
-import { EntityNotFoundError, UserInputError } from '../../common/error/errors';
+import { EntityNotFoundError, InternalServerError, UserInputError } from '../../common/error/errors';
 import {
     AlreadyRefundedError,
     CancelActiveOrderError,
@@ -40,12 +44,17 @@ import {
     FulfillmentStateTransitionError,
     InsufficientStockOnHandError,
     ItemsAlreadyFulfilledError,
+    ManualPaymentStateError,
     MultipleOrderError,
+    NoChangesSpecifiedError,
     NothingToRefundError,
+    OrderModificationStateError,
+    PaymentMethodMissingError,
     PaymentOrderMismatchError,
     PaymentStateTransitionError,
     QuantityTooGreatError,
     RefundOrderStateError,
+    RefundPaymentIdMissingError,
     SettlePaymentError,
 } from '../../common/error/generated-graphql-admin-errors';
 import {
@@ -68,6 +77,7 @@ import { Fulfillment } from '../../entity/fulfillment/fulfillment.entity';
 import { HistoryEntry } from '../../entity/history-entry/history-entry.entity';
 import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { OrderLine } from '../../entity/order-line/order-line.entity';
+import { OrderModification } from '../../entity/order-modification/order-modification.entity';
 import { Order } from '../../entity/order/order.entity';
 import { Payment } from '../../entity/payment/payment.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
@@ -84,6 +94,7 @@ import { FulfillmentState } from '../helpers/fulfillment-state-machine/fulfillme
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from '../helpers/order-calculator/order-calculator';
 import { OrderMerger } from '../helpers/order-merger/order-merger';
+import { OrderModifier } from '../helpers/order-modifier/order-modifier';
 import { OrderState } from '../helpers/order-state-machine/order-state';
 import { OrderStateMachine } from '../helpers/order-state-machine/order-state-machine';
 import { PaymentStateMachine } from '../helpers/payment-state-machine/payment-state-machine';
@@ -94,6 +105,7 @@ import {
     orderItemsAreDelivered,
     orderItemsAreShipped,
     orderTotalIsCovered,
+    totalCoveredByPayments,
 } from '../helpers/utils/order-utils';
 import { patchEntity } from '../helpers/utils/patch-entity';
 import { translateDeep } from '../helpers/utils/translate-entity';
@@ -239,6 +251,7 @@ export class OrderService {
 
     getOrderPayments(ctx: RequestContext, orderId: ID): Promise<Payment[]> {
         return this.connection.getRepository(ctx, Payment).find({
+            relations: ['refunds'],
             where: {
                 order: { id: orderId } as any,
             },
@@ -257,7 +270,7 @@ export class OrderService {
             where: {
                 order: orderId,
             },
-            relations: ['orderItems', 'payment', 'refund'],
+            relations: ['orderItems', 'payment', 'refund', 'surcharges'],
         });
     }
 
@@ -297,6 +310,7 @@ export class OrderService {
             lines: [],
             surcharges: [],
             couponCodes: [],
+            modifications: [],
             shippingAddress: {},
             billingAddress: {},
             subTotal: 0,
@@ -630,6 +644,210 @@ export class OrderService {
         return fulfillment;
     }
 
+    async modifyOrder(
+        ctx: RequestContext,
+        input: ModifyOrderInput,
+    ): Promise<ErrorResultUnion<ModifyOrderResult, Order>> {
+        await this.connection.startTransaction(ctx);
+        const { dryRun } = input;
+        const order = await this.getOrderOrThrow(ctx, input.orderId);
+        const modification = new OrderModification({
+            order,
+            note: input.note || '',
+            orderItems: [],
+            surcharges: [],
+        });
+        const initialTotalWithTax = order.totalWithTax;
+        if (order.state !== 'Modifying') {
+            return new OrderModificationStateError();
+        }
+        if (this.orderModifier.noChangesSpecified(input)) {
+            return new NoChangesSpecifiedError();
+        }
+        const { orderItemsLimit } = this.configService.orderOptions;
+        let currentItemsCount = summate(order.lines, 'quantity');
+        const updatedOrderLineIds: ID[] = [];
+        const refundInput: RefundOrderInput & { orderItems: OrderItem[] } = {
+            lines: [],
+            adjustment: 0,
+            shipping: 0,
+            paymentId: input.refund?.paymentId || '',
+            reason: input.refund?.reason || input.note,
+            orderItems: [],
+        };
+
+        for (const { productVariantId, quantity } of input.addItems ?? []) {
+            if (quantity < 0) {
+                return new NegativeQuantityError();
+            }
+            // TODO: add support for OrderLine customFields
+            const orderLine = await this.getOrCreateItemOrderLine(ctx, order, productVariantId);
+            const correctedQuantity = await this.constrainQuantityToSaleable(
+                ctx,
+                orderLine.productVariant,
+                quantity,
+            );
+            if (orderItemsLimit < currentItemsCount + correctedQuantity) {
+                return new OrderLimitError(orderItemsLimit);
+            } else {
+                currentItemsCount += correctedQuantity;
+            }
+            if (correctedQuantity < quantity) {
+                await this.connection.rollBackTransaction(ctx);
+                return new InsufficientStockError(correctedQuantity, order);
+            }
+            updatedOrderLineIds.push(orderLine.id);
+            const initialQuantity = orderLine.quantity;
+            await this.updateOrderLineQuantity(ctx, orderLine, initialQuantity + correctedQuantity, order);
+            modification.orderItems.push(...orderLine.items.slice(initialQuantity));
+        }
+
+        for (const { orderLineId, quantity } of input.adjustOrderLines ?? []) {
+            if (quantity < 0) {
+                return new NegativeQuantityError();
+            }
+            const orderLine = this.getOrderLineOrThrow(order, orderLineId);
+            const correctedQuantity = await this.constrainQuantityToSaleable(
+                ctx,
+                orderLine.productVariant,
+                quantity,
+            );
+            const resultingOrderTotalQuantity = currentItemsCount + correctedQuantity - orderLine.quantity;
+            if (orderItemsLimit < resultingOrderTotalQuantity) {
+                return new OrderLimitError(orderItemsLimit);
+            } else {
+                currentItemsCount += correctedQuantity;
+            }
+            if (correctedQuantity < quantity) {
+                await this.connection.rollBackTransaction(ctx);
+                return new InsufficientStockError(correctedQuantity, order);
+            } else {
+                const initialLineQuantity = orderLine.quantity;
+                await this.updateOrderLineQuantity(ctx, orderLine, quantity, order);
+                if (correctedQuantity < initialLineQuantity) {
+                    const qtyDelta = initialLineQuantity - correctedQuantity;
+                    refundInput.lines.push({
+                        orderLineId: orderLine.id,
+                        quantity,
+                    });
+                    const cancelledOrderItems = orderLine.items.filter(i => i.cancelled).slice(0, qtyDelta);
+                    refundInput.orderItems.push(...cancelledOrderItems);
+                    modification.orderItems.push(...cancelledOrderItems);
+                } else {
+                    const addedOrderItems = orderLine.items
+                        .filter(i => !i.cancelled)
+                        .slice(initialLineQuantity);
+                    modification.orderItems.push(...addedOrderItems);
+                }
+            }
+            updatedOrderLineIds.push(orderLine.id);
+        }
+
+        for (const surchargeInput of input.surcharges ?? []) {
+            const taxLines =
+                surchargeInput.taxRate != null
+                    ? [
+                          {
+                              taxRate: surchargeInput.taxRate,
+                              description: surchargeInput.taxDescription || '',
+                          },
+                      ]
+                    : [];
+            const surcharge = await this.connection.getRepository(ctx, Surcharge).save(
+                new Surcharge({
+                    sku: surchargeInput.sku || '',
+                    description: surchargeInput.description,
+                    listPrice: surchargeInput.price,
+                    listPriceIncludesTax: surchargeInput.priceIncludesTax,
+                    taxLines,
+                    order,
+                }),
+            );
+            order.surcharges.push(surcharge);
+            modification.surcharges.push(surcharge);
+            if (surcharge.priceWithTax < 0) {
+                refundInput.adjustment += Math.abs(surcharge.priceWithTax);
+            }
+        }
+        if (input.surcharges?.length) {
+            await this.connection.getRepository(ctx, Order).save(order);
+        }
+
+        if (input.updateShippingAddress) {
+            order.shippingAddress = {
+                ...order.shippingAddress,
+                ...input.updateShippingAddress,
+            };
+            if (input.updateShippingAddress.countryCode) {
+                const country = await this.countryService.findOneByCode(
+                    ctx,
+                    input.updateShippingAddress.countryCode,
+                );
+                order.shippingAddress.country = country.name;
+            }
+            await this.connection.getRepository(ctx, Order).save(order);
+            modification.shippingAddressChange = input.updateShippingAddress;
+        }
+
+        if (input.updateBillingAddress) {
+            order.billingAddress = {
+                ...order.billingAddress,
+                ...input.updateShippingAddress,
+            };
+            if (input.updateBillingAddress.countryCode) {
+                const country = await this.countryService.findOneByCode(
+                    ctx,
+                    input.updateBillingAddress.countryCode,
+                );
+                order.billingAddress.country = country.name;
+            }
+            await this.connection.getRepository(ctx, Order).save(order);
+            modification.billingAddressChange = input.updateBillingAddress;
+        }
+
+        const resultingOrder = await this.getOrderOrThrow(ctx, order.id);
+        const updatedOrderLines = resultingOrder.lines.filter(l => updatedOrderLineIds.includes(l.id));
+        await this.orderCalculator.applyPriceAdjustments(ctx, resultingOrder, [], updatedOrderLines, {
+            recalculateShipping: input.options?.recalculateShipping,
+        });
+        const newTotalWithTax = resultingOrder.totalWithTax;
+        const delta = newTotalWithTax - initialTotalWithTax;
+        if (dryRun) {
+            await this.connection.rollBackTransaction(ctx);
+            return resultingOrder;
+        }
+        if (delta < 0) {
+            if (!input.refund) {
+                await this.connection.rollBackTransaction(ctx);
+                return new RefundPaymentIdMissingError();
+            }
+            const existingPayments = await this.getOrderPayments(ctx, order.id);
+            const payment = existingPayments.find(p => idsAreEqual(p.id, input.refund?.paymentId));
+            if (payment) {
+                const refund = await this.paymentMethodService.createRefund(
+                    ctx,
+                    refundInput,
+                    order,
+                    refundInput.orderItems,
+                    payment,
+                );
+                if (!isGraphQlErrorResult(refund)) {
+                    modification.refund = refund;
+                } else {
+                    throw new InternalServerError(refund.message);
+                }
+            }
+        }
+
+        modification.priceChange = delta;
+        const createdModification = await this.connection
+            .getRepository(ctx, OrderModification)
+            .save(modification);
+        await this.connection.getRepository(ctx, Order).save(resultingOrder);
+        await this.connection.commitOpenTransaction(ctx);
+        return this.getOrderOrThrow(ctx, order.id);
+    }
+
     private async handleFulfillmentStateTransitByOrder(
         ctx: RequestContext,
         order: Order,
@@ -737,15 +955,30 @@ export class OrderService {
                         listPriceIncludesTax: priceIncludesTax,
                         adjustments: [],
                         taxLines: [],
+                        line: orderLine,
                     }),
                 );
                 orderLine.items.push(orderItem);
             }
         } else if (quantity < currentQuantity) {
-            const keepItems = orderLine.items.slice(0, quantity);
-            const removeItems = orderLine.items.slice(quantity);
-            orderLine.items = keepItems;
-            await this.connection.getRepository(ctx, OrderItem).remove(removeItems);
+            if (order.active) {
+                // When an Order is still active, it is fine to just delete
+                // any OrderItems that are no longer needed
+                const keepItems = orderLine.items.slice(0, quantity);
+                const removeItems = orderLine.items.slice(quantity);
+                orderLine.items = keepItems;
+                await this.connection.getRepository(ctx, OrderItem).remove(removeItems);
+            } else {
+                // When an Order is not active (i.e. Customer checked out), then we don't want to just
+                // delete the OrderItems - instead we will cancel them
+                const toSetAsCancelled = orderLine.items.filter(i => !i.cancelled).slice(quantity);
+                const soldItems = toSetAsCancelled.filter(i => !!i.fulfillment);
+                const allocatedItems = toSetAsCancelled.filter(i => !i.fulfillment);
+                await this.stockMovementService.createCancellationsForOrderItems(ctx, soldItems);
+                await this.stockMovementService.createReleasesForOrderItems(ctx, allocatedItems);
+                toSetAsCancelled.forEach(i => (i.cancelled = true));
+                await this.connection.getRepository(ctx, OrderItem).save(toSetAsCancelled, { reload: false });
+            }
         }
         await this.connection.getRepository(ctx, OrderLine).save(orderLine);
         return orderLine;
@@ -805,6 +1038,23 @@ export class OrderService {
         return order;
     }
 
+    async addManualPaymentToOrder(
+        ctx: RequestContext,
+        input: ManualPaymentInput,
+    ): Promise<ErrorResultUnion<AddManualPaymentToOrderResult, Order>> {
+        const order = await this.getOrderOrThrow(ctx, input.orderId);
+        if (order.state !== 'ArrangingAdditionalPayment') {
+            return new ManualPaymentStateError();
+        }
+        const existingPayments = await this.getOrderPayments(ctx, order.id);
+        order.payments = existingPayments;
+        const amount = order.totalWithTax - totalCoveredByPayments(order);
+        const payment = await this.paymentMethodService.createManualPayment(ctx, order, amount, input);
+        order.payments.push(payment);
+        await this.connection.getRepository(ctx, Order).save(order, { reload: false });
+        return order;
+    }
+
     async settlePayment(
         ctx: RequestContext,
         paymentId: ID,
@@ -1254,7 +1504,7 @@ export class OrderService {
      * maximum limit specified in the config.
      */
     private assertNotOverOrderItemsLimit(order: Order, quantityToAdd: number) {
-        const currentItemsCount = order.lines.reduce((count, line) => count + line.quantity, 0);
+        const currentItemsCount = summate(order.lines, 'quantity');
         const { orderItemsLimit } = this.configService.orderOptions;
         if (orderItemsLimit < currentItemsCount + quantityToAdd) {
             return new OrderLimitError(orderItemsLimit);
@@ -1277,7 +1527,7 @@ export class OrderService {
             ctx,
             order,
             promotions,
-            updatedOrderLine,
+            updatedOrderLine ? [updatedOrderLine] : [],
         );
         await this.connection.getRepository(ctx, Order).save(order, { reload: false });
         await this.connection.getRepository(ctx, OrderItem).save(updatedItems, { reload: false });

+ 24 - 1
packages/core/src/service/services/payment-method.service.ts

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
 import {
     ConfigArg,
     ConfigArgInput,
+    ManualPaymentInput,
     RefundOrderInput,
     UpdatePaymentMethodInput,
 } from '@vendure/common/lib/generated-types';
@@ -105,6 +106,28 @@ export class PaymentMethodService {
         return payment;
     }
 
+    /**
+     * Creates a Payment from the manual payment mutation in the Admin API
+     */
+    async createManualPayment(ctx: RequestContext, order: Order, amount: number, input: ManualPaymentInput) {
+        const initialState = 'Created';
+        const endState = 'Settled';
+        const payment = await this.connection.getRepository(ctx, Payment).save(
+            new Payment({
+                amount,
+                order,
+                transactionId: input.transactionId,
+                metadata: input.metadata,
+                method: input.method,
+                state: initialState,
+            }),
+        );
+        await this.paymentStateMachine.transition(ctx, order, payment, endState);
+        await this.connection.getRepository(ctx, Payment).save(payment, { reload: false });
+        this.eventBus.publish(new PaymentStateTransitionEvent(initialState, endState, ctx, payment, order));
+        return payment;
+    }
+
     async settlePayment(ctx: RequestContext, payment: Payment, order: Order) {
         const { paymentMethod, handler } = await this.getMethodAndHandler(ctx, payment.method);
         return handler.settlePayment(ctx, order, payment, paymentMethod.configArgs);
@@ -118,7 +141,7 @@ export class PaymentMethodService {
         payment: Payment,
     ): Promise<Refund | RefundStateTransitionError> {
         const { paymentMethod, handler } = await this.getMethodAndHandler(ctx, payment.method);
-        const itemAmount = summate(items, 'unitPriceWithTax');
+        const itemAmount = summate(items, 'proratedUnitPriceWithTax');
         const refundAmount = itemAmount + input.shipping + input.adjustment;
         let refund = new Refund({
             payment,

+ 13 - 0
packages/core/src/service/transaction/transactional-connection.ts

@@ -112,6 +112,19 @@ export class TransactionalConnection {
         }
     }
 
+    /**
+     * @description
+     * Manually rolls back any open transaction. Should be very rarely needed, since the {@link Transaction} decorator
+     * and the internal TransactionInterceptor take care of this automatically. Use-cases include when using the
+     * Transaction decorator in manual mode.
+     */
+    async rollBackTransaction(ctx: RequestContext) {
+        const transactionManager = this.getTransactionManager(ctx);
+        if (transactionManager?.queryRunner?.isTransactionActive) {
+            await transactionManager.queryRunner.rollbackTransaction();
+        }
+    }
+
     /**
      * @description
      * Finds an entity of the given type by ID, or throws an `EntityNotFoundError` if none

+ 1 - 0
packages/core/src/testing/order-test-utils.ts

@@ -157,5 +157,6 @@ export function createOrder(
         lines,
         shippingLines: [],
         surcharges: [],
+        modifications: [],
     });
 }

+ 169 - 13
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -336,6 +336,18 @@ export type Mutation = {
     transitionOrderToState?: Maybe<TransitionOrderToStateResult>;
     transitionFulfillmentToState: TransitionFulfillmentToStateResult;
     setOrderCustomFields?: Maybe<Order>;
+    /**
+     * Allows an Order to be modified after it has been completed by the Customer. The Order must first
+     * be in the `Modifying` state.
+     */
+    modifyOrder: ModifyOrderResult;
+    /**
+     * Used to manually create a new Payment against an Order. This is used when a completed Order
+     * has been modified (using `modifyOrder`) and the price has increased. The extra payment
+     * can then be manually arranged by the administrator, and the details used to create a new
+     * Payment.
+     */
+    addManualPaymentToOrder: AddManualPaymentToOrderResult;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
     /** Create a new ProductOptionGroup */
@@ -645,6 +657,14 @@ export type MutationSetOrderCustomFieldsArgs = {
     input: UpdateOrderInput;
 };
 
+export type MutationModifyOrderArgs = {
+    input: ModifyOrderInput;
+};
+
+export type MutationAddManualPaymentToOrderArgs = {
+    input: ManualPaymentInput;
+};
+
 export type MutationUpdatePaymentMethodArgs = {
     input: UpdatePaymentMethodInput;
 };
@@ -1111,18 +1131,6 @@ export type UpdateFacetValueInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type Fulfillment = Node & {
-    nextStates: Array<Scalars['String']>;
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    orderItems: Array<OrderItem>;
-    state: Scalars['String'];
-    method: Scalars['String'];
-    trackingCode?: Maybe<Scalars['String']>;
-    customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type UpdateGlobalSettingsInput = {
     availableLanguages?: Maybe<Array<LanguageCode>>;
     trackInventory?: Maybe<Scalars['Boolean']>;
@@ -1229,6 +1237,7 @@ export type JobQueue = {
 
 export type Order = Node & {
     nextStates: Array<Scalars['String']>;
+    modifications: Array<OrderModification>;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
@@ -1293,6 +1302,31 @@ export type OrderHistoryArgs = {
     options?: Maybe<HistoryEntryListOptions>;
 };
 
+export type Fulfillment = Node & {
+    nextStates: Array<Scalars['String']>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    orderItems: Array<OrderItem>;
+    state: Scalars['String'];
+    method: Scalars['String'];
+    trackingCode?: Maybe<Scalars['String']>;
+    customFields?: Maybe<Scalars['JSON']>;
+};
+
+export type OrderModification = Node & {
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    priceChange: Scalars['Int'];
+    note: Scalars['String'];
+    orderItems?: Maybe<Array<OrderItem>>;
+    surcharges?: Maybe<Array<Surcharge>>;
+    payment?: Maybe<Payment>;
+    refund?: Maybe<Refund>;
+    isSettled: Scalars['Boolean'];
+};
+
 export type UpdateOrderInput = {
     id: Scalars['ID'];
     customFields?: Maybe<Scalars['JSON']>;
@@ -1341,6 +1375,67 @@ export type UpdateOrderNoteInput = {
     isPublic?: Maybe<Scalars['Boolean']>;
 };
 
+export type AdministratorPaymentInput = {
+    paymentMethod?: Maybe<Scalars['String']>;
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
+export type AdministratorRefundInput = {
+    paymentId: Scalars['ID'];
+    reason?: Maybe<Scalars['String']>;
+};
+
+export type ModifyOrderOptions = {
+    freezePromotions?: Maybe<Scalars['Boolean']>;
+    recalculateShipping?: Maybe<Scalars['Boolean']>;
+};
+
+export type UpdateOrderAddressInput = {
+    fullName?: Maybe<Scalars['String']>;
+    company?: Maybe<Scalars['String']>;
+    streetLine1?: Maybe<Scalars['String']>;
+    streetLine2?: Maybe<Scalars['String']>;
+    city?: Maybe<Scalars['String']>;
+    province?: Maybe<Scalars['String']>;
+    postalCode?: Maybe<Scalars['String']>;
+    countryCode?: Maybe<Scalars['String']>;
+    phoneNumber?: Maybe<Scalars['String']>;
+};
+
+export type ModifyOrderInput = {
+    dryRun: Scalars['Boolean'];
+    orderId: Scalars['ID'];
+    addItems?: Maybe<Array<AddItemInput>>;
+    adjustOrderLines?: Maybe<Array<OrderLineInput>>;
+    surcharges?: Maybe<Array<SurchargeInput>>;
+    updateShippingAddress?: Maybe<UpdateOrderAddressInput>;
+    updateBillingAddress?: Maybe<UpdateOrderAddressInput>;
+    note?: Maybe<Scalars['String']>;
+    refund?: Maybe<AdministratorRefundInput>;
+    options?: Maybe<ModifyOrderOptions>;
+};
+
+export type AddItemInput = {
+    productVariantId: Scalars['ID'];
+    quantity: Scalars['Int'];
+};
+
+export type SurchargeInput = {
+    description: Scalars['String'];
+    sku?: Maybe<Scalars['String']>;
+    price: Scalars['Int'];
+    priceIncludesTax: Scalars['Boolean'];
+    taxRate?: Maybe<Scalars['Float']>;
+    taxDescription?: Maybe<Scalars['String']>;
+};
+
+export type ManualPaymentInput = {
+    orderId: Scalars['ID'];
+    method: Scalars['String'];
+    transactionId?: Maybe<Scalars['String']>;
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
 /** Returned if the Payment settlement fails */
 export type SettlePaymentError = ErrorResult & {
     errorCode: ErrorCode;
@@ -1457,6 +1552,45 @@ export type FulfillmentStateTransitionError = ErrorResult & {
     toState: Scalars['String'];
 };
 
+/** Returned when attempting to modify the contents of an Order that is not in the `Modifying` state. */
+export type OrderModificationStateError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned when a call to modifyOrder fails to specify any changes */
+export type NoChangesSpecifiedError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to modifyOrder fails to include a paymentMethod even
+ * though the price has increased as a result of the changes.
+ */
+export type PaymentMethodMissingError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to modifyOrder fails to include a refundPaymentId even
+ * though the price has decreased as a result of the changes.
+ */
+export type RefundPaymentIdMissingError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/**
+ * Returned when a call to addManualPaymentToOrder is made but the Order
+ * is not in the required state.
+ */
+export type ManualPaymentStateError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 export type SettlePaymentResult =
@@ -1497,6 +1631,18 @@ export type SettleRefundResult = Refund | RefundStateTransitionError;
 
 export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
 
+export type ModifyOrderResult =
+    | Order
+    | NoChangesSpecifiedError
+    | OrderModificationStateError
+    | PaymentMethodMissingError
+    | RefundPaymentIdMissingError
+    | OrderLimitError
+    | NegativeQuantityError
+    | InsufficientStockError;
+
+export type AddManualPaymentToOrderResult = Order | ManualPaymentStateError;
+
 export type PaymentMethodList = PaginatedList & {
     items: Array<PaymentMethod>;
     totalItems: Scalars['Int'];
@@ -2203,6 +2349,11 @@ export enum ErrorCode {
     REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
     PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
     FULFILLMENT_STATE_TRANSITION_ERROR = 'FULFILLMENT_STATE_TRANSITION_ERROR',
+    ORDER_MODIFICATION_STATE_ERROR = 'ORDER_MODIFICATION_STATE_ERROR',
+    NO_CHANGES_SPECIFIED_ERROR = 'NO_CHANGES_SPECIFIED_ERROR',
+    PAYMENT_METHOD_MISSING_ERROR = 'PAYMENT_METHOD_MISSING_ERROR',
+    REFUND_PAYMENT_ID_MISSING_ERROR = 'REFUND_PAYMENT_ID_MISSING_ERROR',
+    MANUAL_PAYMENT_STATE_ERROR = 'MANUAL_PAYMENT_STATE_ERROR',
     PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
     MISSING_CONDITIONS_ERROR = 'MISSING_CONDITIONS_ERROR',
     NATIVE_AUTH_STRATEGY_ERROR = 'NATIVE_AUTH_STRATEGY_ERROR',
@@ -3442,7 +3593,7 @@ export type OrderLine = Node & {
     discounts: Array<Adjustment>;
     taxLines: Array<TaxLine>;
     order: Order;
-    customFields?: Maybe<Scalars['JSON']>;
+    customFields?: Maybe<OrderLineCustomFields>;
 };
 
 export type Payment = Node & {
@@ -4158,6 +4309,11 @@ export type HistoryEntrySortParameter = {
     updatedAt?: Maybe<SortOrder>;
 };
 
+export type OrderLineCustomFields = {
+    test?: Maybe<Scalars['String']>;
+    test2?: Maybe<Scalars['String']>;
+};
+
 export type AuthenticationInput = {
     native?: Maybe<NativeAuthInput>;
 };

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema-admin.json


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema-shop.json


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff