Kaynağa Gözat

feat(core): Improved error handling for Admin API mutations

Relates to #437

feat(core): Improved error handling for remaining admin mutations
feat(core): Improved error handling for admin promotion mutations
feat(core): Improved error handling for admin order mutations
feat(core): Improved error handling for global settings mutations
feat(core): Improved error handling for customer mutations
feat(core): Improved error handling auth mutations
Michael Bromley 5 yıl önce
ebeveyn
işleme
af49054172
66 değiştirilmiş dosya ile 3131 ekleme ve 1028 silme
  1. 231 22
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  2. 10 11
      packages/common/src/generated-shop-types.ts
  3. 230 24
      packages/common/src/generated-types.ts
  4. 10 10
      packages/core/e2e/auth.e2e-spec.ts
  5. 32 18
      packages/core/e2e/authentication-strategy.e2e-spec.ts
  6. 65 34
      packages/core/e2e/channel.e2e-spec.ts
  7. 3 1
      packages/core/e2e/custom-fields.e2e-spec.ts
  8. 59 16
      packages/core/e2e/customer.e2e-spec.ts
  9. 149 0
      packages/core/e2e/global-settings.e2e-spec.ts
  10. 30 0
      packages/core/e2e/graphql/fragments.ts
  11. 441 82
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  12. 10 10
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  13. 43 27
      packages/core/e2e/graphql/shared-definitions.ts
  14. 14 5
      packages/core/e2e/order-promotion.e2e-spec.ts
  15. 363 241
      packages/core/e2e/order.e2e-spec.ts
  16. 49 32
      packages/core/e2e/product.e2e-spec.ts
  17. 113 87
      packages/core/e2e/promotion.e2e-spec.ts
  18. 3 1
      packages/core/e2e/session-management.e2e-spec.ts
  19. 9 19
      packages/core/e2e/shop-auth.e2e-spec.ts
  20. 1 1
      packages/core/e2e/shop-customer.e2e-spec.ts
  21. 1 1
      packages/core/src/api/middleware/translate-error-result-interceptor.ts
  22. 19 8
      packages/core/src/api/resolvers/admin/auth.resolver.ts
  23. 17 7
      packages/core/src/api/resolvers/admin/channel.resolver.ts
  24. 5 3
      packages/core/src/api/resolvers/admin/customer.resolver.ts
  25. 12 5
      packages/core/src/api/resolvers/admin/global-settings.resolver.ts
  26. 25 5
      packages/core/src/api/resolvers/admin/order.resolver.ts
  27. 3 1
      packages/core/src/api/resolvers/admin/product.resolver.ts
  28. 14 7
      packages/core/src/api/resolvers/admin/promotion.resolver.ts
  29. 43 12
      packages/core/src/api/resolvers/base/base-auth.resolver.ts
  30. 31 35
      packages/core/src/api/resolvers/shop/shop-auth.resolver.ts
  31. 5 2
      packages/core/src/api/resolvers/shop/shop-customer.resolver.ts
  32. 6 3
      packages/core/src/api/schema/admin-api/auth.api.graphql
  33. 12 2
      packages/core/src/api/schema/admin-api/channel.api.graphql
  34. 5 2
      packages/core/src/api/schema/admin-api/customer.api.graphql
  35. 14 1
      packages/core/src/api/schema/admin-api/global-settings.api.graphql
  36. 118 6
      packages/core/src/api/schema/admin-api/order.api.graphql
  37. 10 1
      packages/core/src/api/schema/admin-api/product.api.graphql
  38. 11 2
      packages/core/src/api/schema/admin-api/promotion.api.graphql
  39. 18 0
      packages/core/src/api/schema/common/common-types.graphql
  40. 5 21
      packages/core/src/api/schema/shop-api/shop.api.graphql
  41. 0 4
      packages/core/src/api/schema/type/auth.type.graphql
  42. 1 1
      packages/core/src/common/error/error-result.ts
  43. 302 6
      packages/core/src/common/error/generated-graphql-admin-errors.ts
  44. 34 26
      packages/core/src/common/error/generated-graphql-shop-errors.ts
  45. 1 0
      packages/core/src/common/index.ts
  46. 5 6
      packages/core/src/config/auth/native-authentication-strategy.ts
  47. 15 28
      packages/core/src/i18n/messages/en.json
  48. 6 5
      packages/core/src/service/services/auth.service.ts
  49. 34 17
      packages/core/src/service/services/channel.service.ts
  50. 59 28
      packages/core/src/service/services/customer.service.ts
  51. 2 1
      packages/core/src/service/services/history.service.ts
  52. 97 60
      packages/core/src/service/services/order.service.ts
  53. 7 2
      packages/core/src/service/services/payment-method.service.ts
  54. 5 5
      packages/core/src/service/services/product.service.ts
  55. 18 14
      packages/core/src/service/services/promotion.service.ts
  56. 1 1
      packages/core/src/service/services/tax-category.service.ts
  57. 20 0
      packages/dev-server/dev-config.ts
  58. 231 22
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  59. 5 5
      packages/elasticsearch-plugin/src/elasticsearch.service.ts
  60. 8 8
      packages/elasticsearch-plugin/src/indexer.controller.ts
  61. 5 5
      packages/elasticsearch-plugin/src/types.ts
  62. 8 3
      packages/testing/src/data-population/mock-data.service.ts
  63. 13 5
      packages/testing/src/simple-graphql-client.ts
  64. 0 0
      schema-admin.json
  65. 0 0
      schema-shop.json
  66. 15 11
      scripts/codegen/plugins/graphql-errors-plugin.ts

+ 231 - 22
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -16,6 +16,11 @@ export type Scalars = {
     Upload: any;
 };
 
+export type AddFulfillmentToOrderResult =
+    | Fulfillment
+    | EmptyOrderLineSelectionError
+    | ItemsAlreadyFulfilledError;
+
 export type AddNoteToCustomerInput = {
     id: Scalars['ID'];
     note: Scalars['String'];
@@ -102,6 +107,13 @@ export type AdministratorSortParameter = {
     emailAddress?: Maybe<SortOrder>;
 };
 
+/** Returned if an attempting to refund an OrderItem which has already been refunded */
+export type AlreadyRefundedError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    refundId: Scalars['ID'];
+};
+
 export type Asset = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -178,6 +190,8 @@ export type AuthenticationMethod = Node & {
     strategy: Scalars['String'];
 };
 
+export type AuthenticationResult = CurrentUser | InvalidCredentialsError;
+
 export type BooleanCustomFieldConfig = CustomField & {
     name: Scalars['String'];
     type: Scalars['String'];
@@ -192,6 +206,13 @@ export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
 };
 
+/** Returned if an attempting to cancel lines from an Order which is still active */
+export type CancelActiveOrderError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    orderState: Scalars['String'];
+};
+
 export type Cancellation = Node &
     StockMovement & {
         id: Scalars['ID'];
@@ -211,6 +232,14 @@ export type CancelOrderInput = {
     reason?: Maybe<Scalars['String']>;
 };
 
+export type CancelOrderResult =
+    | Order
+    | EmptyOrderLineSelectionError
+    | QuantityTooGreatError
+    | MultipleOrderError
+    | CancelActiveOrderError
+    | OrderStateTransitionError;
+
 export type Channel = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -224,6 +253,17 @@ export type Channel = Node & {
     pricesIncludeTax: Scalars['Boolean'];
 };
 
+/**
+ * Returned when the default LanguageCode of a Channel is no longer found in the `availableLanguages`
+ * of the GlobalSettings
+ */
+export type ChannelDefaultLanguageError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    language: Scalars['String'];
+    channelCode: Scalars['String'];
+};
+
 export type Collection = Node & {
     isPrivate: Scalars['Boolean'];
     id: Scalars['ID'];
@@ -436,6 +476,8 @@ export type CreateChannelInput = {
     defaultShippingZoneId: Scalars['ID'];
 };
 
+export type CreateChannelResult = Channel | LanguageNotAvailableError;
+
 export type CreateCollectionInput = {
     isPrivate?: Maybe<Scalars['Boolean']>;
     featuredAssetId?: Maybe<Scalars['ID']>;
@@ -474,6 +516,8 @@ export type CreateCustomerInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CreateCustomerResult = Customer | EmailAddressConflictError;
+
 export type CreateFacetInput = {
     code: Scalars['String'];
     isPrivate: Scalars['Boolean'];
@@ -553,6 +597,8 @@ export type CreatePromotionInput = {
     actions: Array<ConfigurableOperationInput>;
 };
 
+export type CreatePromotionResult = Promotion | MissingConditionsError;
+
 export type CreateRoleInput = {
     code: Scalars['String'];
     description: Scalars['String'];
@@ -1093,10 +1139,42 @@ export enum DeletionResult {
     NOT_DELETED = 'NOT_DELETED',
 }
 
+/** Retured when attemting to create a Customer with an email address already registered to an existing User. */
+export type EmailAddressConflictError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned if no OrderLines have been specified for the operation */
+export type EmptyOrderLineSelectionError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export enum ErrorCode {
     UNKNOWN_ERROR = 'UNKNOWN_ERROR',
     MIME_TYPE_ERROR = 'MIME_TYPE_ERROR',
+    INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS_ERROR',
+    NATIVE_AUTH_STRATEGY_ERROR = 'NATIVE_AUTH_STRATEGY_ERROR',
+    LANGUAGE_NOT_AVAILABLE_ERROR = 'LANGUAGE_NOT_AVAILABLE_ERROR',
+    EMAIL_ADDRESS_CONFLICT_ERROR = 'EMAIL_ADDRESS_CONFLICT_ERROR',
+    CHANNEL_DEFAULT_LANGUAGE_ERROR = 'CHANNEL_DEFAULT_LANGUAGE_ERROR',
+    SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
+    PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
     ORDER_STATE_TRANSITION_ERROR = 'ORDER_STATE_TRANSITION_ERROR',
+    EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
+    ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
+    QUANTITY_TOO_GREAT_ERROR = 'QUANTITY_TOO_GREAT_ERROR',
+    MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
+    CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
+    NOTHING_TO_REFUND_ERROR = 'NOTHING_TO_REFUND_ERROR',
+    PAYMENT_ORDER_MISMATCH_ERROR = 'PAYMENT_ORDER_MISMATCH_ERROR',
+    REFUND_ORDER_STATE_ERROR = 'REFUND_ORDER_STATE_ERROR',
+    ALREADY_REFUNDED_ERROR = 'ALREADY_REFUNDED_ERROR',
+    REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
+    FULFILLMENT_STATE_TRANSITION_ERROR = 'FULFILLMENT_STATE_TRANSITION_ERROR',
+    PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
+    MISSING_CONDITIONS_ERROR = 'MISSING_CONDITIONS_ERROR',
 }
 
 export type ErrorResult = {
@@ -1221,6 +1299,15 @@ export type Fulfillment = Node & {
     trackingCode?: Maybe<Scalars['String']>;
 };
 
+/** Returned when there is an error in transitioning the Fulfillment state */
+export type FulfillmentStateTransitionError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    transitionError: Scalars['String'];
+    fromState: Scalars['String'];
+    toState: Scalars['String'];
+};
+
 export type FulfillOrderInput = {
     lines: Array<OrderLineInput>;
     method: Scalars['String'];
@@ -1317,6 +1404,18 @@ export type IntCustomFieldConfig = CustomField & {
     step?: Maybe<Scalars['Int']>;
 };
 
+/** Returned if the user authentication credentials are not valid */
+export type InvalidCredentialsError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned if the specified items are already part of a Fulfillment */
+export type ItemsAlreadyFulfilledError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type Job = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -1710,6 +1809,12 @@ export enum LanguageCode {
     zu = 'zu',
 }
 
+export type LanguageNotAvailableError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    languageCode: Scalars['String'];
+};
+
 export type LocaleStringCustomFieldConfig = CustomField & {
     name: Scalars['String'];
     type: Scalars['String'];
@@ -1732,10 +1837,6 @@ export enum LogicalOperator {
     OR = 'OR',
 }
 
-export type LoginResult = {
-    user: CurrentUser;
-};
-
 export type MimeTypeError = ErrorResult & {
     code: ErrorCode;
     message: Scalars['String'];
@@ -1743,12 +1844,24 @@ export type MimeTypeError = ErrorResult & {
     mimeType: Scalars['String'];
 };
 
+/** Returned if a PromotionCondition has neither a couponCode nor any conditions set */
+export type MissingConditionsError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type MoveCollectionInput = {
     collectionId: Scalars['ID'];
     parentId: Scalars['ID'];
     index: Scalars['Int'];
 };
 
+/** Returned if an operation has specified OrderLines from multiple Orders */
+export type MultipleOrderError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type Mutation = {
     /** Create a new Administrator */
     createAdministrator: Administrator;
@@ -1770,14 +1883,14 @@ export type Mutation = {
      * Authenticates the user using the native authentication strategy. This mutation
      * is an alias for `authenticate({ native: { ... }})`
      */
-    login: LoginResult;
+    login: NativeAuthenticationResult;
     /** Authenticates the user using a named authentication strategy */
-    authenticate: LoginResult;
-    logout: Scalars['Boolean'];
+    authenticate: AuthenticationResult;
+    logout: Success;
     /** Create a new Channel */
-    createChannel: Channel;
+    createChannel: CreateChannelResult;
     /** Update an existing Channel */
-    updateChannel: Channel;
+    updateChannel: UpdateChannelResult;
     /** Delete a Channel */
     deleteChannel: DeletionResponse;
     /** Create a new Collection */
@@ -1805,9 +1918,9 @@ export type Mutation = {
     /** Remove Customers from a CustomerGroup */
     removeCustomersFromGroup: CustomerGroup;
     /** Create a new Customer. If a password is provided, a new User will also be created an linked to the Customer. */
-    createCustomer: Customer;
+    createCustomer: CreateCustomerResult;
     /** Update an existing Customer */
-    updateCustomer: Customer;
+    updateCustomer: UpdateCustomerResult;
     /** Delete a Customer */
     deleteCustomer: DeletionResponse;
     /** Create a new Address and associate it with the Customer specified by customerId */
@@ -1831,20 +1944,20 @@ export type Mutation = {
     updateFacetValues: Array<FacetValue>;
     /** Delete one or more FacetValues */
     deleteFacetValues: Array<DeletionResponse>;
-    updateGlobalSettings: GlobalSettings;
+    updateGlobalSettings: UpdateGlobalSettingsResult;
     importProducts?: Maybe<ImportInfo>;
     /** Remove all settled jobs in the given queues olfer than the given date. Returns the number of jobs deleted. */
     removeSettledJobs: Scalars['Int'];
-    settlePayment: Payment;
-    fulfillOrder: Fulfillment;
-    cancelOrder: Order;
-    refundOrder: Refund;
-    settleRefund: Refund;
+    settlePayment: SettlePaymentResult;
+    addFulfillmentToOrder: AddFulfillmentToOrderResult;
+    cancelOrder: CancelOrderResult;
+    refundOrder: RefundOrderResult;
+    settleRefund: SettleRefundResult;
     addNoteToOrder: Order;
     updateOrderNote: HistoryEntry;
     deleteOrderNote: DeletionResponse;
     transitionOrderToState?: Maybe<TransitionOrderToStateResult>;
-    transitionFulfillmentToState: Fulfillment;
+    transitionFulfillmentToState: TransitionFulfillmentToStateResult;
     setOrderCustomFields?: Maybe<Order>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
@@ -1866,7 +1979,7 @@ export type Mutation = {
     /** Add an OptionGroup to a Product */
     addOptionGroupToProduct: Product;
     /** Remove an OptionGroup from a Product */
-    removeOptionGroupFromProduct: Product;
+    removeOptionGroupFromProduct: RemoveOptionGroupFromProductResult;
     /** Create a set of ProductVariants based on the OptionGroups assigned to the given Product */
     createProductVariants: Array<Maybe<ProductVariant>>;
     /** Update existing ProductVariants */
@@ -1877,8 +1990,8 @@ export type Mutation = {
     assignProductsToChannel: Array<Product>;
     /** Removes Products from the specified Channel */
     removeProductsFromChannel: Array<Product>;
-    createPromotion: Promotion;
-    updatePromotion: Promotion;
+    createPromotion: CreatePromotionResult;
+    updatePromotion: UpdatePromotionResult;
     deletePromotion: DeletionResponse;
     /** Create a new Role */
     createRole: Role;
@@ -2105,7 +2218,7 @@ export type MutationSettlePaymentArgs = {
     id: Scalars['ID'];
 };
 
-export type MutationFulfillOrderArgs = {
+export type MutationAddFulfillmentToOrderArgs = {
     input: FulfillOrderInput;
 };
 
@@ -2291,15 +2404,29 @@ export type MutationRemoveMembersFromZoneArgs = {
     memberIds: Array<Scalars['ID']>;
 };
 
+export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
+
 export type NativeAuthInput = {
     username: Scalars['String'];
     password: Scalars['String'];
 };
 
+/** Retured when attempting an operation that relies on the NativeAuthStrategy, if that strategy is not configured. */
+export type NativeAuthStrategyError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type Node = {
     id: Scalars['ID'];
 };
 
+/** Returned if an attempting to refund an Order but neither items nor shipping refund was specified */
+export type NothingToRefundError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type NumberOperators = {
     eq?: Maybe<Scalars['Float']>;
     lt?: Maybe<Scalars['Float']>;
@@ -2509,6 +2636,21 @@ export type PaymentMethodSortParameter = {
     code?: Maybe<SortOrder>;
 };
 
+/** Returned if an attempting to refund a Payment against OrderLines from a different Order */
+export type PaymentOrderMismatchError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned when there is an error in transitioning the Payment state */
+export type PaymentStateTransitionError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    transitionError: Scalars['String'];
+    fromState: Scalars['String'];
+    toState: Scalars['String'];
+};
+
 /**
  * "
  * @description
@@ -2640,6 +2782,13 @@ export type ProductOptionGroupTranslationInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type ProductOptionInUseError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    optionGroupCode: Scalars['String'];
+    productVariantCount: Scalars['Int'];
+};
+
 export type ProductOptionTranslation = {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -2816,6 +2965,12 @@ export type PromotionSortParameter = {
     name?: Maybe<SortOrder>;
 };
 
+/** Returned if the specified quantity of an OrderLine is greater than the number of items in that line */
+export type QuantityTooGreatError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type Query = {
     administrators: AdministratorList;
     administrator?: Maybe<Administrator>;
@@ -3063,6 +3218,35 @@ export type RefundOrderInput = {
     reason?: Maybe<Scalars['String']>;
 };
 
+export type RefundOrderResult =
+    | Refund
+    | QuantityTooGreatError
+    | NothingToRefundError
+    | OrderStateTransitionError
+    | MultipleOrderError
+    | PaymentOrderMismatchError
+    | RefundOrderStateError
+    | AlreadyRefundedError
+    | RefundStateTransitionError;
+
+/** Returned if an attempting to refund an Order which is not in the expected state */
+export type RefundOrderStateError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    orderState: Scalars['String'];
+};
+
+/** Returned when there is an error in transitioning the Refund state */
+export type RefundStateTransitionError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    transitionError: Scalars['String'];
+    fromState: Scalars['String'];
+    toState: Scalars['String'];
+};
+
+export type RemoveOptionGroupFromProductResult = Product | ProductOptionInUseError;
+
 export type RemoveProductsFromChannelInput = {
     productIds: Array<Scalars['ID']>;
     channelId: Scalars['ID'];
@@ -3197,11 +3381,26 @@ export type ServerConfig = {
     customFieldConfig: CustomFields;
 };
 
+/** Returned if the Payment settlement fails */
+export type SettlePaymentError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    paymentErrorMessage: Scalars['String'];
+};
+
+export type SettlePaymentResult =
+    | Payment
+    | SettlePaymentError
+    | PaymentStateTransitionError
+    | OrderStateTransitionError;
+
 export type SettleRefundInput = {
     id: Scalars['ID'];
     transactionId: Scalars['String'];
 };
 
+export type SettleRefundResult = Refund | RefundStateTransitionError;
+
 export type ShippingMethod = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -3401,6 +3600,8 @@ export type TestShippingMethodResult = {
     quote?: Maybe<TestShippingMethodQuote>;
 };
 
+export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 export type UpdateAddressInput = {
@@ -3445,6 +3646,8 @@ export type UpdateChannelInput = {
     defaultShippingZoneId?: Maybe<Scalars['ID']>;
 };
 
+export type UpdateChannelResult = Channel | LanguageNotAvailableError;
+
 export type UpdateCollectionInput = {
     id: Scalars['ID'];
     isPrivate?: Maybe<Scalars['Boolean']>;
@@ -3492,6 +3695,8 @@ export type UpdateCustomerNoteInput = {
     note: Scalars['String'];
 };
 
+export type UpdateCustomerResult = Customer | EmailAddressConflictError;
+
 export type UpdateFacetInput = {
     id: Scalars['ID'];
     isPrivate?: Maybe<Scalars['Boolean']>;
@@ -3513,6 +3718,8 @@ export type UpdateGlobalSettingsInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type UpdateGlobalSettingsResult = GlobalSettings | ChannelDefaultLanguageError;
+
 export type UpdateOrderInput = {
     id: Scalars['ID'];
     customFields?: Maybe<Scalars['JSON']>;
@@ -3582,6 +3789,8 @@ export type UpdatePromotionInput = {
     actions?: Maybe<Array<ConfigurableOperationInput>>;
 };
 
+export type UpdatePromotionResult = Promotion | MissingConditionsError;
+
 export type UpdateRoleInput = {
     id: Scalars['ID'];
     code?: Maybe<Scalars['String']>;

+ 10 - 11
packages/common/src/generated-shop-types.ts

@@ -130,6 +130,8 @@ export type AuthenticationMethod = Node & {
     strategy: Scalars['String'];
 };
 
+export type AuthenticationResult = CurrentUser | InvalidCredentialsError;
+
 export type BooleanCustomFieldConfig = CustomField & {
     __typename?: 'BooleanCustomFieldConfig';
     name: Scalars['String'];
@@ -873,12 +875,12 @@ export enum ErrorCode {
     PAYMENT_DECLINED_ERROR = 'PAYMENT_DECLINED_ERROR',
     ALREADY_LOGGED_IN_ERROR = 'ALREADY_LOGGED_IN_ERROR',
     EMAIL_ADDRESS_CONFLICT_ERROR = 'EMAIL_ADDRESS_CONFLICT_ERROR',
-    MISSING_PASSWORD_ERROR = 'MISSING_PASSWORD_ERROR',
+    INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS_ERROR',
     NATIVE_AUTH_STRATEGY_ERROR = 'NATIVE_AUTH_STRATEGY_ERROR',
+    MISSING_PASSWORD_ERROR = 'MISSING_PASSWORD_ERROR',
     VERIFICATION_TOKEN_INVALID_ERROR = 'VERIFICATION_TOKEN_INVALID_ERROR',
     VERIFICATION_TOKEN_EXPIRED_ERROR = 'VERIFICATION_TOKEN_EXPIRED_ERROR',
     PASSWORD_ALREADY_SET_ERROR = 'PASSWORD_ALREADY_SET_ERROR',
-    INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS_ERROR',
     IDENTIFIER_CHANGE_TOKEN_INVALID_ERROR = 'IDENTIFIER_CHANGE_TOKEN_INVALID_ERROR',
     IDENTIFIER_CHANGE_TOKEN_EXPIRED_ERROR = 'IDENTIFIER_CHANGE_TOKEN_EXPIRED_ERROR',
     PASSWORD_RESET_TOKEN_INVALID_ERROR = 'PASSWORD_RESET_TOKEN_INVALID_ERROR',
@@ -1090,7 +1092,7 @@ export type IntCustomFieldConfig = CustomField & {
     step?: Maybe<Scalars['Int']>;
 };
 
-/** Returned if the user credentials are not valid */
+/** Returned if the user authentication credentials are not valid */
 export type InvalidCredentialsError = ErrorResult & {
     __typename?: 'InvalidCredentialsError';
     code: ErrorCode;
@@ -1447,11 +1449,6 @@ export enum LogicalOperator {
     OR = 'OR',
 }
 
-export type LoginResult = {
-    __typename?: 'LoginResult';
-    user: CurrentUser;
-};
-
 /** Retured when attemting to register or verify a customer account without a password, when one is required. */
 export type MissingPasswordError = ErrorResult & {
     __typename?: 'MissingPasswordError';
@@ -1497,11 +1494,11 @@ export type Mutation = {
      * Authenticates the user using the native authentication strategy. This mutation
      * is an alias for `authenticate({ native: { ... }})`
      */
-    login: LoginResult;
+    login: NativeAuthenticationResult;
     /** Authenticates the user using a named authentication strategy */
-    authenticate: LoginResult;
+    authenticate: AuthenticationResult;
     /** End the current authenticated session */
-    logout: Scalars['Boolean'];
+    logout: Success;
     /**
      * Register a Customer account with the given credentials. There are three possible registration flows:
      *
@@ -1678,6 +1675,8 @@ export type MutationResetPasswordArgs = {
     password: Scalars['String'];
 };
 
+export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
+
 export type NativeAuthInput = {
     username: Scalars['String'];
     password: Scalars['String'];

+ 230 - 24
packages/common/src/generated-types.ts

@@ -16,6 +16,8 @@ export type Scalars = {
   Upload: any;
 };
 
+export type AddFulfillmentToOrderResult = Fulfillment | EmptyOrderLineSelectionError | ItemsAlreadyFulfilledError;
+
 export type AddNoteToCustomerInput = {
   id: Scalars['ID'];
   note: Scalars['String'];
@@ -106,6 +108,14 @@ export type AdministratorSortParameter = {
   emailAddress?: Maybe<SortOrder>;
 };
 
+/** Returned if an attempting to refund an OrderItem which has already been refunded */
+export type AlreadyRefundedError = ErrorResult & {
+  __typename?: 'AlreadyRefundedError';
+  code: ErrorCode;
+  message: Scalars['String'];
+  refundId: Scalars['ID'];
+};
+
 export type Asset = Node & {
   __typename?: 'Asset';
   id: Scalars['ID'];
@@ -185,6 +195,8 @@ export type AuthenticationMethod = Node & {
   strategy: Scalars['String'];
 };
 
+export type AuthenticationResult = CurrentUser | InvalidCredentialsError;
+
 export type BooleanCustomFieldConfig = CustomField & {
   __typename?: 'BooleanCustomFieldConfig';
   name: Scalars['String'];
@@ -200,6 +212,14 @@ export type BooleanOperators = {
   eq?: Maybe<Scalars['Boolean']>;
 };
 
+/** Returned if an attempting to cancel lines from an Order which is still active */
+export type CancelActiveOrderError = ErrorResult & {
+  __typename?: 'CancelActiveOrderError';
+  code: ErrorCode;
+  message: Scalars['String'];
+  orderState: Scalars['String'];
+};
+
 export type Cancellation = Node & StockMovement & {
   __typename?: 'Cancellation';
   id: Scalars['ID'];
@@ -219,6 +239,8 @@ export type CancelOrderInput = {
   reason?: Maybe<Scalars['String']>;
 };
 
+export type CancelOrderResult = Order | EmptyOrderLineSelectionError | QuantityTooGreatError | MultipleOrderError | CancelActiveOrderError | OrderStateTransitionError;
+
 export type Channel = Node & {
   __typename?: 'Channel';
   id: Scalars['ID'];
@@ -233,6 +255,18 @@ export type Channel = Node & {
   pricesIncludeTax: Scalars['Boolean'];
 };
 
+/**
+ * Returned when the default LanguageCode of a Channel is no longer found in the `availableLanguages`
+ * of the GlobalSettings
+ */
+export type ChannelDefaultLanguageError = ErrorResult & {
+  __typename?: 'ChannelDefaultLanguageError';
+  code: ErrorCode;
+  message: Scalars['String'];
+  language: Scalars['String'];
+  channelCode: Scalars['String'];
+};
+
 export type Collection = Node & {
   __typename?: 'Collection';
   isPrivate: Scalars['Boolean'];
@@ -458,6 +492,8 @@ export type CreateChannelInput = {
   defaultShippingZoneId: Scalars['ID'];
 };
 
+export type CreateChannelResult = Channel | LanguageNotAvailableError;
+
 export type CreateCollectionInput = {
   isPrivate?: Maybe<Scalars['Boolean']>;
   featuredAssetId?: Maybe<Scalars['ID']>;
@@ -496,6 +532,8 @@ export type CreateCustomerInput = {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CreateCustomerResult = Customer | EmailAddressConflictError;
+
 export type CreateFacetInput = {
   code: Scalars['String'];
   isPrivate: Scalars['Boolean'];
@@ -575,6 +613,8 @@ export type CreatePromotionInput = {
   actions: Array<ConfigurableOperationInput>;
 };
 
+export type CreatePromotionResult = Promotion | MissingConditionsError;
+
 export type CreateRoleInput = {
   code: Scalars['String'];
   description: Scalars['String'];
@@ -1122,10 +1162,44 @@ export enum DeletionResult {
   NOT_DELETED = 'NOT_DELETED'
 }
 
+/** Retured when attemting to create a Customer with an email address already registered to an existing User. */
+export type EmailAddressConflictError = ErrorResult & {
+  __typename?: 'EmailAddressConflictError';
+  code: ErrorCode;
+  message: Scalars['String'];
+};
+
+/** Returned if no OrderLines have been specified for the operation */
+export type EmptyOrderLineSelectionError = ErrorResult & {
+  __typename?: 'EmptyOrderLineSelectionError';
+  code: ErrorCode;
+  message: Scalars['String'];
+};
+
 export enum ErrorCode {
   UNKNOWN_ERROR = 'UNKNOWN_ERROR',
   MIME_TYPE_ERROR = 'MIME_TYPE_ERROR',
-  ORDER_STATE_TRANSITION_ERROR = 'ORDER_STATE_TRANSITION_ERROR'
+  INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS_ERROR',
+  NATIVE_AUTH_STRATEGY_ERROR = 'NATIVE_AUTH_STRATEGY_ERROR',
+  LANGUAGE_NOT_AVAILABLE_ERROR = 'LANGUAGE_NOT_AVAILABLE_ERROR',
+  EMAIL_ADDRESS_CONFLICT_ERROR = 'EMAIL_ADDRESS_CONFLICT_ERROR',
+  CHANNEL_DEFAULT_LANGUAGE_ERROR = 'CHANNEL_DEFAULT_LANGUAGE_ERROR',
+  SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
+  PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
+  ORDER_STATE_TRANSITION_ERROR = 'ORDER_STATE_TRANSITION_ERROR',
+  EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
+  ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
+  QUANTITY_TOO_GREAT_ERROR = 'QUANTITY_TOO_GREAT_ERROR',
+  MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
+  CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
+  NOTHING_TO_REFUND_ERROR = 'NOTHING_TO_REFUND_ERROR',
+  PAYMENT_ORDER_MISMATCH_ERROR = 'PAYMENT_ORDER_MISMATCH_ERROR',
+  REFUND_ORDER_STATE_ERROR = 'REFUND_ORDER_STATE_ERROR',
+  ALREADY_REFUNDED_ERROR = 'ALREADY_REFUNDED_ERROR',
+  REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
+  FULFILLMENT_STATE_TRANSITION_ERROR = 'FULFILLMENT_STATE_TRANSITION_ERROR',
+  PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
+  MISSING_CONDITIONS_ERROR = 'MISSING_CONDITIONS_ERROR'
 }
 
 export type ErrorResult = {
@@ -1258,6 +1332,16 @@ export type Fulfillment = Node & {
   trackingCode?: Maybe<Scalars['String']>;
 };
 
+/** Returned when there is an error in transitioning the Fulfillment state */
+export type FulfillmentStateTransitionError = ErrorResult & {
+  __typename?: 'FulfillmentStateTransitionError';
+  code: ErrorCode;
+  message: Scalars['String'];
+  transitionError: Scalars['String'];
+  fromState: Scalars['String'];
+  toState: Scalars['String'];
+};
+
 export type FulfillOrderInput = {
   lines: Array<OrderLineInput>;
   method: Scalars['String'];
@@ -1359,6 +1443,20 @@ export type IntCustomFieldConfig = CustomField & {
   step?: Maybe<Scalars['Int']>;
 };
 
+/** Returned if the user authentication credentials are not valid */
+export type InvalidCredentialsError = ErrorResult & {
+  __typename?: 'InvalidCredentialsError';
+  code: ErrorCode;
+  message: Scalars['String'];
+};
+
+/** Returned if the specified items are already part of a Fulfillment */
+export type ItemsAlreadyFulfilledError = ErrorResult & {
+  __typename?: 'ItemsAlreadyFulfilledError';
+  code: ErrorCode;
+  message: Scalars['String'];
+};
+
 export type Job = Node & {
   __typename?: 'Job';
   id: Scalars['ID'];
@@ -1756,6 +1854,13 @@ export enum LanguageCode {
   zu = 'zu'
 }
 
+export type LanguageNotAvailableError = ErrorResult & {
+  __typename?: 'LanguageNotAvailableError';
+  code: ErrorCode;
+  message: Scalars['String'];
+  languageCode: Scalars['String'];
+};
+
 export type LocaleStringCustomFieldConfig = CustomField & {
   __typename?: 'LocaleStringCustomFieldConfig';
   name: Scalars['String'];
@@ -1780,11 +1885,6 @@ export enum LogicalOperator {
   OR = 'OR'
 }
 
-export type LoginResult = {
-  __typename?: 'LoginResult';
-  user: CurrentUser;
-};
-
 export type MimeTypeError = ErrorResult & {
   __typename?: 'MimeTypeError';
   code: ErrorCode;
@@ -1793,12 +1893,26 @@ export type MimeTypeError = ErrorResult & {
   mimeType: Scalars['String'];
 };
 
+/** Returned if a PromotionCondition has neither a couponCode nor any conditions set */
+export type MissingConditionsError = ErrorResult & {
+  __typename?: 'MissingConditionsError';
+  code: ErrorCode;
+  message: Scalars['String'];
+};
+
 export type MoveCollectionInput = {
   collectionId: Scalars['ID'];
   parentId: Scalars['ID'];
   index: Scalars['Int'];
 };
 
+/** Returned if an operation has specified OrderLines from multiple Orders */
+export type MultipleOrderError = ErrorResult & {
+  __typename?: 'MultipleOrderError';
+  code: ErrorCode;
+  message: Scalars['String'];
+};
+
 export type Mutation = {
   __typename?: 'Mutation';
   /** Create a new Administrator */
@@ -1821,14 +1935,14 @@ export type Mutation = {
    * Authenticates the user using the native authentication strategy. This mutation
    * is an alias for `authenticate({ native: { ... }})`
    */
-  login: LoginResult;
+  login: NativeAuthenticationResult;
   /** Authenticates the user using a named authentication strategy */
-  authenticate: LoginResult;
-  logout: Scalars['Boolean'];
+  authenticate: AuthenticationResult;
+  logout: Success;
   /** Create a new Channel */
-  createChannel: Channel;
+  createChannel: CreateChannelResult;
   /** Update an existing Channel */
-  updateChannel: Channel;
+  updateChannel: UpdateChannelResult;
   /** Delete a Channel */
   deleteChannel: DeletionResponse;
   /** Create a new Collection */
@@ -1856,9 +1970,9 @@ export type Mutation = {
   /** Remove Customers from a CustomerGroup */
   removeCustomersFromGroup: CustomerGroup;
   /** Create a new Customer. If a password is provided, a new User will also be created an linked to the Customer. */
-  createCustomer: Customer;
+  createCustomer: CreateCustomerResult;
   /** Update an existing Customer */
-  updateCustomer: Customer;
+  updateCustomer: UpdateCustomerResult;
   /** Delete a Customer */
   deleteCustomer: DeletionResponse;
   /** Create a new Address and associate it with the Customer specified by customerId */
@@ -1882,20 +1996,20 @@ export type Mutation = {
   updateFacetValues: Array<FacetValue>;
   /** Delete one or more FacetValues */
   deleteFacetValues: Array<DeletionResponse>;
-  updateGlobalSettings: GlobalSettings;
+  updateGlobalSettings: UpdateGlobalSettingsResult;
   importProducts?: Maybe<ImportInfo>;
   /** Remove all settled jobs in the given queues olfer than the given date. Returns the number of jobs deleted. */
   removeSettledJobs: Scalars['Int'];
-  settlePayment: Payment;
-  fulfillOrder: Fulfillment;
-  cancelOrder: Order;
-  refundOrder: Refund;
-  settleRefund: Refund;
+  settlePayment: SettlePaymentResult;
+  addFulfillmentToOrder: AddFulfillmentToOrderResult;
+  cancelOrder: CancelOrderResult;
+  refundOrder: RefundOrderResult;
+  settleRefund: SettleRefundResult;
   addNoteToOrder: Order;
   updateOrderNote: HistoryEntry;
   deleteOrderNote: DeletionResponse;
   transitionOrderToState?: Maybe<TransitionOrderToStateResult>;
-  transitionFulfillmentToState: Fulfillment;
+  transitionFulfillmentToState: TransitionFulfillmentToStateResult;
   setOrderCustomFields?: Maybe<Order>;
   /** Update an existing PaymentMethod */
   updatePaymentMethod: PaymentMethod;
@@ -1917,7 +2031,7 @@ export type Mutation = {
   /** Add an OptionGroup to a Product */
   addOptionGroupToProduct: Product;
   /** Remove an OptionGroup from a Product */
-  removeOptionGroupFromProduct: Product;
+  removeOptionGroupFromProduct: RemoveOptionGroupFromProductResult;
   /** Create a set of ProductVariants based on the OptionGroups assigned to the given Product */
   createProductVariants: Array<Maybe<ProductVariant>>;
   /** Update existing ProductVariants */
@@ -1928,8 +2042,8 @@ export type Mutation = {
   assignProductsToChannel: Array<Product>;
   /** Removes Products from the specified Channel */
   removeProductsFromChannel: Array<Product>;
-  createPromotion: Promotion;
-  updatePromotion: Promotion;
+  createPromotion: CreatePromotionResult;
+  updatePromotion: UpdatePromotionResult;
   deletePromotion: DeletionResponse;
   /** Create a new Role */
   createRole: Role;
@@ -2201,7 +2315,7 @@ export type MutationSettlePaymentArgs = {
 };
 
 
-export type MutationFulfillOrderArgs = {
+export type MutationAddFulfillmentToOrderArgs = {
   input: FulfillOrderInput;
 };
 
@@ -2431,15 +2545,31 @@ export type MutationRemoveMembersFromZoneArgs = {
   memberIds: Array<Scalars['ID']>;
 };
 
+export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
+
 export type NativeAuthInput = {
   username: Scalars['String'];
   password: Scalars['String'];
 };
 
+/** Retured when attempting an operation that relies on the NativeAuthStrategy, if that strategy is not configured. */
+export type NativeAuthStrategyError = ErrorResult & {
+  __typename?: 'NativeAuthStrategyError';
+  code: ErrorCode;
+  message: Scalars['String'];
+};
+
 export type Node = {
   id: Scalars['ID'];
 };
 
+/** Returned if an attempting to refund an Order but neither items nor shipping refund was specified */
+export type NothingToRefundError = ErrorResult & {
+  __typename?: 'NothingToRefundError';
+  code: ErrorCode;
+  message: Scalars['String'];
+};
+
 export type NumberOperators = {
   eq?: Maybe<Scalars['Float']>;
   lt?: Maybe<Scalars['Float']>;
@@ -2660,6 +2790,23 @@ export type PaymentMethodSortParameter = {
   code?: Maybe<SortOrder>;
 };
 
+/** Returned if an attempting to refund a Payment against OrderLines from a different Order */
+export type PaymentOrderMismatchError = ErrorResult & {
+  __typename?: 'PaymentOrderMismatchError';
+  code: ErrorCode;
+  message: Scalars['String'];
+};
+
+/** Returned when there is an error in transitioning the Payment state */
+export type PaymentStateTransitionError = ErrorResult & {
+  __typename?: 'PaymentStateTransitionError';
+  code: ErrorCode;
+  message: Scalars['String'];
+  transitionError: Scalars['String'];
+  fromState: Scalars['String'];
+  toState: Scalars['String'];
+};
+
 /**
  * "
  * @description
@@ -2797,6 +2944,14 @@ export type ProductOptionGroupTranslationInput = {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type ProductOptionInUseError = ErrorResult & {
+  __typename?: 'ProductOptionInUseError';
+  code: ErrorCode;
+  message: Scalars['String'];
+  optionGroupCode: Scalars['String'];
+  productVariantCount: Scalars['Int'];
+};
+
 export type ProductOptionTranslation = {
   __typename?: 'ProductOptionTranslation';
   id: Scalars['ID'];
@@ -2981,6 +3136,13 @@ export type PromotionSortParameter = {
   name?: Maybe<SortOrder>;
 };
 
+/** Returned if the specified quantity of an OrderLine is greater than the number of items in that line */
+export type QuantityTooGreatError = ErrorResult & {
+  __typename?: 'QuantityTooGreatError';
+  code: ErrorCode;
+  message: Scalars['String'];
+};
+
 export type Query = {
   __typename?: 'Query';
   administrators: AdministratorList;
@@ -3270,6 +3432,28 @@ export type RefundOrderInput = {
   reason?: Maybe<Scalars['String']>;
 };
 
+export type RefundOrderResult = Refund | QuantityTooGreatError | NothingToRefundError | OrderStateTransitionError | MultipleOrderError | PaymentOrderMismatchError | RefundOrderStateError | AlreadyRefundedError | RefundStateTransitionError;
+
+/** Returned if an attempting to refund an Order which is not in the expected state */
+export type RefundOrderStateError = ErrorResult & {
+  __typename?: 'RefundOrderStateError';
+  code: ErrorCode;
+  message: Scalars['String'];
+  orderState: Scalars['String'];
+};
+
+/** Returned when there is an error in transitioning the Refund state */
+export type RefundStateTransitionError = ErrorResult & {
+  __typename?: 'RefundStateTransitionError';
+  code: ErrorCode;
+  message: Scalars['String'];
+  transitionError: Scalars['String'];
+  fromState: Scalars['String'];
+  toState: Scalars['String'];
+};
+
+export type RemoveOptionGroupFromProductResult = Product | ProductOptionInUseError;
+
 export type RemoveProductsFromChannelInput = {
   productIds: Array<Scalars['ID']>;
   channelId: Scalars['ID'];
@@ -3411,11 +3595,23 @@ export type ServerConfig = {
   customFieldConfig: CustomFields;
 };
 
+/** Returned if the Payment settlement fails */
+export type SettlePaymentError = ErrorResult & {
+  __typename?: 'SettlePaymentError';
+  code: ErrorCode;
+  message: Scalars['String'];
+  paymentErrorMessage: Scalars['String'];
+};
+
+export type SettlePaymentResult = Payment | SettlePaymentError | PaymentStateTransitionError | OrderStateTransitionError;
+
 export type SettleRefundInput = {
   id: Scalars['ID'];
   transactionId: Scalars['String'];
 };
 
+export type SettleRefundResult = Refund | RefundStateTransitionError;
+
 export type ShippingMethod = Node & {
   __typename?: 'ShippingMethod';
   id: Scalars['ID'];
@@ -3628,6 +3824,8 @@ export type TestShippingMethodResult = {
   quote?: Maybe<TestShippingMethodQuote>;
 };
 
+export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 export type UpdateAddressInput = {
@@ -3672,6 +3870,8 @@ export type UpdateChannelInput = {
   defaultShippingZoneId?: Maybe<Scalars['ID']>;
 };
 
+export type UpdateChannelResult = Channel | LanguageNotAvailableError;
+
 export type UpdateCollectionInput = {
   id: Scalars['ID'];
   isPrivate?: Maybe<Scalars['Boolean']>;
@@ -3719,6 +3919,8 @@ export type UpdateCustomerNoteInput = {
   note: Scalars['String'];
 };
 
+export type UpdateCustomerResult = Customer | EmailAddressConflictError;
+
 export type UpdateFacetInput = {
   id: Scalars['ID'];
   isPrivate?: Maybe<Scalars['Boolean']>;
@@ -3740,6 +3942,8 @@ export type UpdateGlobalSettingsInput = {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type UpdateGlobalSettingsResult = GlobalSettings | ChannelDefaultLanguageError;
+
 export type UpdateOrderInput = {
   id: Scalars['ID'];
   customFields?: Maybe<Scalars['JSON']>;
@@ -3809,6 +4013,8 @@ export type UpdatePromotionInput = {
   actions?: Maybe<Array<ConfigurableOperationInput>>;
 };
 
+export type UpdatePromotionResult = Promotion | MissingConditionsError;
+
 export type UpdateRoleInput = {
   id: Scalars['ID'];
   code?: Maybe<Scalars['String']>;

+ 10 - 10
packages/core/e2e/auth.e2e-spec.ts

@@ -11,6 +11,7 @@ import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-conf
 import {
     CreateAdministrator,
     CreateRole,
+    ErrorCode,
     GetCustomerList,
     Me,
     MutationCreateProductArgs,
@@ -76,12 +77,11 @@ describe('Authorization & permissions', () => {
                 customerEmailAddress = customers.items[0].emailAddress;
             });
 
-            it(
-                'cannot login',
-                assertThrowsWithMessage(async () => {
-                    await adminClient.asUserWithCredentials(customerEmailAddress, 'test');
-                }, 'The credentials did not match. Please check and try again'),
-            );
+            it('cannot login', async () => {
+                const result = await adminClient.asUserWithCredentials(customerEmailAddress, 'test');
+
+                expect(result.code).toBe(ErrorCode.INVALID_CREDENTIALS_ERROR);
+            });
         });
 
         describe('ReadCatalog permission', () => {
@@ -153,7 +153,9 @@ describe('Authorization & permissions', () => {
                     gql`
                         mutation CanCreateCustomer($input: CreateCustomerInput!) {
                             createCustomer(input: $input) {
-                                id
+                                ... on Customer {
+                                    id
+                                }
                             }
                         }
                     `,
@@ -214,9 +216,7 @@ describe('Authorization & permissions', () => {
 
         const role = roleResult.createRole;
 
-        const identifier = `${code}@${Math.random()
-            .toString(16)
-            .substr(2, 8)}`;
+        const identifier = `${code}@${Math.random().toString(16).substr(2, 8)}`;
         const password = `test`;
 
         const adminResult = await adminClient.query<

+ 32 - 18
packages/core/e2e/authentication-strategy.e2e-spec.ts

@@ -1,3 +1,4 @@
+import { ErrorCode } from '@vendure/common/lib/generated-shop-types';
 import { pick } from '@vendure/common/lib/pick';
 import { mergeConfig } from '@vendure/core';
 import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
@@ -10,8 +11,11 @@ import { NativeAuthenticationStrategy } from '../src/config/auth/native-authenti
 import { DefaultLogger } from '../src/config/logger/default-logger';
 
 import { TestAuthenticationStrategy, VALID_AUTH_TOKEN } from './fixtures/test-authentication-strategies';
+import { CURRENT_USER_FRAGMENT } from './graphql/fragments';
 import {
     Authenticate,
+    CurrentUser,
+    CurrentUserFragment,
     GetCustomerHistory,
     GetCustomers,
     GetCustomerUserAuth,
@@ -48,6 +52,10 @@ describe('AuthenticationStrategy', () => {
         await server.destroy();
     });
 
+    const currentUserGuard: ErrorResultGuard<CurrentUserFragment> = createErrorResultGuard<
+        CurrentUserFragment
+    >(input => input.identifier != null);
+
     describe('external auth', () => {
         const userData = {
             email: 'test@email.com',
@@ -56,24 +64,24 @@ describe('AuthenticationStrategy', () => {
         };
         let newCustomerId: string;
 
-        it(
-            'fails with a bad token',
-            assertThrowsWithMessage(async () => {
-                await shopClient.query(AUTHENTICATE, {
-                    input: {
-                        test_strategy: {
-                            token: 'bad-token',
-                        },
+        it('fails with a bad token', async () => {
+            const { authenticate } = await shopClient.query(AUTHENTICATE, {
+                input: {
+                    test_strategy: {
+                        token: 'bad-token',
                     },
-                });
-            }, 'The credentials did not match. Please check and try again'),
-        );
+                },
+            });
+
+            expect(authenticate.message).toBe('The provided credentials are invalid');
+            expect(authenticate.code).toBe(ErrorCode.INVALID_CREDENTIALS_ERROR);
+        });
 
         it('creates a new Customer with valid token', async () => {
             const { customers: before } = await adminClient.query<GetCustomers.Query>(GET_CUSTOMERS);
             expect(before.totalItems).toBe(1);
 
-            const result = await shopClient.query<Authenticate.Mutation>(AUTHENTICATE, {
+            const { authenticate } = await shopClient.query<Authenticate.Mutation>(AUTHENTICATE, {
                 input: {
                     test_strategy: {
                         token: VALID_AUTH_TOKEN,
@@ -81,7 +89,9 @@ describe('AuthenticationStrategy', () => {
                     },
                 },
             });
-            expect(result.authenticate.user.identifier).toEqual(userData.email);
+            currentUserGuard.assertSuccess(authenticate);
+
+            expect(authenticate.identifier).toEqual(userData.email);
 
             const { customers: after } = await adminClient.query<GetCustomers.Query>(GET_CUSTOMERS);
             expect(after.totalItems).toBe(2);
@@ -141,7 +151,7 @@ describe('AuthenticationStrategy', () => {
         });
 
         it('logging in again re-uses created User & Customer', async () => {
-            const result = await shopClient.query<Authenticate.Mutation>(AUTHENTICATE, {
+            const { authenticate } = await shopClient.query<Authenticate.Mutation>(AUTHENTICATE, {
                 input: {
                     test_strategy: {
                         token: VALID_AUTH_TOKEN,
@@ -149,7 +159,9 @@ describe('AuthenticationStrategy', () => {
                     },
                 },
             });
-            expect(result.authenticate.user.identifier).toEqual(userData.email);
+            currentUserGuard.assertSuccess(authenticate);
+
+            expect(authenticate.identifier).toEqual(userData.email);
 
             const { customers: after } = await adminClient.query<GetCustomers.Query>(GET_CUSTOMERS);
             expect(after.totalItems).toBe(2);
@@ -210,12 +222,14 @@ describe('AuthenticationStrategy', () => {
 const AUTHENTICATE = gql`
     mutation Authenticate($input: AuthenticationInput!) {
         authenticate(input: $input) {
-            user {
-                id
-                identifier
+            ...CurrentUser
+            ... on ErrorResult {
+                code
+                message
             }
         }
     }
+    ${CURRENT_USER_FRAGMENT}
 `;
 
 const GET_CUSTOMERS = gql`

+ 65 - 34
packages/core/e2e/channel.e2e-spec.ts

@@ -1,20 +1,27 @@
 /* tslint:disable:no-non-null-assertion */
 import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
-import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
+import {
+    createErrorResultGuard,
+    createTestEnvironment,
+    E2E_DEFAULT_CHANNEL_TOKEN,
+    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 { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import {
     AssignProductsToChannel,
+    ChannelFragment,
     CreateAdministrator,
     CreateChannel,
     CreateRole,
     CurrencyCode,
     DeleteChannel,
     DeletionResult,
+    ErrorCode,
     GetChannels,
     GetCustomerList,
     GetProductWithVariants,
@@ -23,7 +30,7 @@ import {
     Permission,
     RemoveProductsFromChannel,
     UpdateChannel,
-    UpdateGlobalSettings,
+    UpdateGlobalLanguages,
 } from './graphql/generated-e2e-admin-types';
 import {
     ASSIGN_PRODUCT_TO_CHANNEL,
@@ -45,6 +52,10 @@ describe('Channels', () => {
     let secondChannelAdminRole: CreateRole.CreateRole;
     let customerUser: GetCustomerList.Items;
 
+    const channelGuard: ErrorResultGuard<ChannelFragment> = createErrorResultGuard<ChannelFragment>(
+        input => !!input.defaultLanguageCode,
+    );
+
     beforeAll(async () => {
         await server.init({
             initialData,
@@ -66,6 +77,30 @@ describe('Channels', () => {
         await server.destroy();
     });
 
+    it('createChannel returns error result defaultLanguageCode not available', async () => {
+        const { createChannel } = await adminClient.query<CreateChannel.Mutation, CreateChannel.Variables>(
+            CREATE_CHANNEL,
+            {
+                input: {
+                    code: 'second-channel',
+                    token: SECOND_CHANNEL_TOKEN,
+                    defaultLanguageCode: LanguageCode.zh,
+                    currencyCode: CurrencyCode.GBP,
+                    pricesIncludeTax: true,
+                    defaultShippingZoneId: 'T_1',
+                    defaultTaxZoneId: 'T_1',
+                },
+            },
+        );
+        channelGuard.assertErrorResult(createChannel);
+
+        expect(createChannel.message).toBe(
+            'Language "zh" is not available. First enable it via GlobalSettings and try again',
+        );
+        expect(createChannel.errorCode).toBe(ErrorCode.LANGUAGE_NOT_AVAILABLE_ERROR);
+        expect(createChannel.languageCode).toBe('zh');
+    });
+
     it('create a new Channel', async () => {
         const { createChannel } = await adminClient.query<CreateChannel.Mutation, CreateChannel.Variables>(
             CREATE_CHANNEL,
@@ -81,6 +116,7 @@ describe('Channels', () => {
                 },
             },
         );
+        channelGuard.assertSuccess(createChannel);
 
         expect(createChannel).toEqual({
             id: 'T_2',
@@ -356,21 +392,28 @@ describe('Channels', () => {
     });
 
     describe('setting defaultLanguage', () => {
-        it(
-            'throws if languageCode not in availableLanguages',
-            assertThrowsWithMessage(async () => {
-                await adminClient.query<UpdateChannel.Mutation, UpdateChannel.Variables>(UPDATE_CHANNEL, {
-                    input: {
-                        id: 'T_1',
-                        defaultLanguageCode: LanguageCode.zh,
-                    },
-                });
-            }, 'Language "zh" is not available. First enable it via GlobalSettings and try again.'),
-        );
+        it('returns error result if languageCode not in availableLanguages', async () => {
+            const { updateChannel } = await adminClient.query<
+                UpdateChannel.Mutation,
+                UpdateChannel.Variables
+            >(UPDATE_CHANNEL, {
+                input: {
+                    id: 'T_1',
+                    defaultLanguageCode: LanguageCode.zh,
+                },
+            });
+            channelGuard.assertErrorResult(updateChannel);
+
+            expect(updateChannel.message).toBe(
+                'Language "zh" is not available. First enable it via GlobalSettings and try again',
+            );
+            expect(updateChannel.errorCode).toBe(ErrorCode.LANGUAGE_NOT_AVAILABLE_ERROR);
+            expect(updateChannel.languageCode).toBe('zh');
+        });
 
         it('allows setting to an available language', async () => {
-            await adminClient.query<UpdateGlobalSettings.Mutation, UpdateGlobalSettings.Variables>(
-                UPDATE_GLOBAL_SETTINGS,
+            await adminClient.query<UpdateGlobalLanguages.Mutation, UpdateGlobalLanguages.Variables>(
+                UPDATE_GLOBAL_LANGUAGES,
                 {
                     input: {
                         availableLanguages: [LanguageCode.en, LanguageCode.zh],
@@ -390,20 +433,6 @@ describe('Channels', () => {
 
             expect(updateChannel.defaultLanguageCode).toBe(LanguageCode.zh);
         });
-
-        it(
-            'attempting to remove availableLanguage when used by a Channel throws',
-            assertThrowsWithMessage(async () => {
-                await adminClient.query<UpdateGlobalSettings.Mutation, UpdateGlobalSettings.Variables>(
-                    UPDATE_GLOBAL_SETTINGS,
-                    {
-                        input: {
-                            availableLanguages: [LanguageCode.en],
-                        },
-                    },
-                );
-            }, 'Cannot remove make language "zh" unavailable as it is used as the defaultLanguage by the channel "__default_channel__"'),
-        );
     });
 
     it('deleteChannel', async () => {
@@ -461,11 +490,13 @@ const DELETE_CHANNEL = gql`
     }
 `;
 
-const UPDATE_GLOBAL_SETTINGS = gql`
-    mutation UpdateGlobalSettings($input: UpdateGlobalSettingsInput!) {
+const UPDATE_GLOBAL_LANGUAGES = gql`
+    mutation UpdateGlobalLanguages($input: UpdateGlobalSettingsInput!) {
         updateGlobalSettings(input: $input) {
-            id
-            availableLanguages
+            ... on GlobalSettings {
+                id
+                availableLanguages
+            }
         }
     }
 `;

+ 3 - 1
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -327,7 +327,9 @@ describe('Custom fields', () => {
             await adminClient.query(gql`
                 mutation {
                     updateCustomer(input: { id: "T_1", customFields: { score: 5 } }) {
-                        id
+                        ... on Customer {
+                            id
+                        }
                     }
                 }
             `);

+ 59 - 16
packages/core/e2e/customer.e2e-spec.ts

@@ -21,10 +21,12 @@ import {
     AddNoteToCustomer,
     CreateAddress,
     CreateCustomer,
+    CustomerFragment,
     DeleteCustomer,
     DeleteCustomerAddress,
     DeleteCustomerNote,
     DeletionResult,
+    ErrorCode,
     GetCustomer,
     GetCustomerHistory,
     GetCustomerList,
@@ -78,6 +80,10 @@ describe('Customer resolver', () => {
     let secondCustomer: GetCustomerList.Items;
     let thirdCustomer: GetCustomerList.Items;
 
+    const customerErrorGuard: ErrorResultGuard<CustomerFragment> = createErrorResultGuard<CustomerFragment>(
+        input => !!input.emailAddress,
+    );
+
     beforeAll(async () => {
         await server.init({
             initialData,
@@ -400,6 +406,7 @@ describe('Customer resolver', () => {
                     lastName: 'Customer',
                 },
             });
+            customerErrorGuard.assertSuccess(createCustomer);
 
             expect(createCustomer.user!.verified).toBe(false);
             expect(sendEmailFn).toHaveBeenCalledTimes(1);
@@ -420,27 +427,62 @@ describe('Customer resolver', () => {
                 },
                 password: 'test',
             });
+            customerErrorGuard.assertSuccess(createCustomer);
 
             expect(createCustomer.user!.verified).toBe(true);
             expect(sendEmailFn).toHaveBeenCalledTimes(0);
         });
 
-        it(
-            'throws when using an existing, non-deleted emailAddress',
-            assertThrowsWithMessage(async () => {
-                const { createCustomer } = await adminClient.query<
-                    CreateCustomer.Mutation,
-                    CreateCustomer.Variables
-                >(CREATE_CUSTOMER, {
-                    input: {
-                        emailAddress: 'test2@test.com',
-                        firstName: 'New',
-                        lastName: 'Customer',
-                    },
-                    password: 'test',
-                });
-            }, 'The email address must be unique'),
-        );
+        it('return error result when using an existing, non-deleted emailAddress', async () => {
+            const { createCustomer } = await adminClient.query<
+                CreateCustomer.Mutation,
+                CreateCustomer.Variables
+            >(CREATE_CUSTOMER, {
+                input: {
+                    emailAddress: 'test2@test.com',
+                    firstName: 'New',
+                    lastName: 'Customer',
+                },
+                password: 'test',
+            });
+            customerErrorGuard.assertErrorResult(createCustomer);
+
+            expect(createCustomer.message).toBe('The email address is not available.');
+            expect(createCustomer.code).toBe(ErrorCode.EMAIL_ADDRESS_CONFLICT_ERROR);
+        });
+    });
+
+    describe('update', () => {
+        it('returns error result when emailAddress not available', async () => {
+            const { updateCustomer } = await adminClient.query<
+                UpdateCustomer.Mutation,
+                UpdateCustomer.Variables
+            >(UPDATE_CUSTOMER, {
+                input: {
+                    id: thirdCustomer.id,
+                    emailAddress: firstCustomer.emailAddress,
+                },
+            });
+            customerErrorGuard.assertErrorResult(updateCustomer);
+
+            expect(updateCustomer.message).toBe('The email address is not available.');
+            expect(updateCustomer.code).toBe(ErrorCode.EMAIL_ADDRESS_CONFLICT_ERROR);
+        });
+
+        it('succeeds when emailAddress is available', async () => {
+            const { updateCustomer } = await adminClient.query<
+                UpdateCustomer.Mutation,
+                UpdateCustomer.Variables
+            >(UPDATE_CUSTOMER, {
+                input: {
+                    id: thirdCustomer.id,
+                    emailAddress: 'unique-email@test.com',
+                },
+            });
+            customerErrorGuard.assertSuccess(updateCustomer);
+
+            expect(updateCustomer.emailAddress).toBe('unique-email@test.com');
+        });
     });
 
     describe('deletion', () => {
@@ -509,6 +551,7 @@ describe('Customer resolver', () => {
                     lastName: 'Customer',
                 },
             });
+            customerErrorGuard.assertSuccess(createCustomer);
 
             expect(createCustomer.emailAddress).toBe(thirdCustomer.emailAddress);
             expect(createCustomer.firstName).toBe('Reusing Email');

+ 149 - 0
packages/core/e2e/global-settings.e2e-spec.ts

@@ -0,0 +1,149 @@
+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 { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import {
+    GetGlobalSettings,
+    GlobalSettingsFragment,
+    LanguageCode,
+    UpdateGlobalSettings,
+} from './graphql/generated-e2e-admin-types';
+
+describe('GlobalSettings resolver', () => {
+    const { server, adminClient } = createTestEnvironment({
+        ...testConfig,
+        ...{
+            customFields: {
+                Customer: [{ name: 'age', type: 'int' }],
+            },
+        },
+    });
+    let globalSettings: GlobalSettingsFragment;
+
+    const globalSettingsGuard: ErrorResultGuard<GlobalSettingsFragment> = createErrorResultGuard<
+        GlobalSettingsFragment
+    >(input => !!input.availableLanguages);
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+        const result = await adminClient.query<GetGlobalSettings.Query>(GET_GLOBAL_SETTINGS);
+        globalSettings = result.globalSettings;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('globalSettings query', () => {
+        it('includes basic settings', () => {
+            expect(globalSettings.availableLanguages).toEqual([LanguageCode.en]);
+            expect(globalSettings.trackInventory).toBe(false);
+        });
+
+        it('includes orderProcess', () => {
+            expect(globalSettings.serverConfig.orderProcess[0]).toEqual({
+                name: 'AddingItems',
+                to: ['ArrangingPayment', 'Cancelled'],
+            });
+        });
+
+        it('includes permittedAssetTypes', () => {
+            expect(globalSettings.serverConfig.permittedAssetTypes).toEqual([
+                'image/*',
+                'video/*',
+                'audio/*',
+                '.pdf',
+            ]);
+        });
+
+        it('includes customFieldConfig', () => {
+            expect(globalSettings.serverConfig.customFieldConfig.Customer).toEqual([{ name: 'age' }]);
+        });
+    });
+
+    describe('update', () => {
+        it('returns error result when removing required language', async () => {
+            const { updateGlobalSettings } = await adminClient.query<
+                UpdateGlobalSettings.Mutation,
+                UpdateGlobalSettings.Variables
+            >(UPDATE_GLOBAL_SETTINGS, {
+                input: {
+                    availableLanguages: [LanguageCode.zh],
+                },
+            });
+            globalSettingsGuard.assertErrorResult(updateGlobalSettings);
+
+            expect(updateGlobalSettings.message).toBe(
+                'Cannot make language "en" unavailable as it is used as the defaultLanguage by the channel "__default_channel__"',
+            );
+        });
+
+        it('successful update', async () => {
+            const { updateGlobalSettings } = await adminClient.query<
+                UpdateGlobalSettings.Mutation,
+                UpdateGlobalSettings.Variables
+            >(UPDATE_GLOBAL_SETTINGS, {
+                input: {
+                    availableLanguages: [LanguageCode.en, LanguageCode.zh],
+                    trackInventory: true,
+                },
+            });
+            globalSettingsGuard.assertSuccess(updateGlobalSettings);
+
+            expect(updateGlobalSettings.availableLanguages).toEqual([LanguageCode.en, LanguageCode.zh]);
+            expect(updateGlobalSettings.trackInventory).toBe(true);
+        });
+    });
+});
+
+const GLOBAL_SETTINGS_FRAGMENT = gql`
+    fragment GlobalSettings on GlobalSettings {
+        id
+        availableLanguages
+        trackInventory
+        serverConfig {
+            orderProcess {
+                name
+                to
+            }
+            permittedAssetTypes
+            customFieldConfig {
+                Customer {
+                    ... on CustomField {
+                        name
+                    }
+                }
+            }
+        }
+    }
+`;
+
+const GET_GLOBAL_SETTINGS = gql`
+    query GetGlobalSettings {
+        globalSettings {
+            ...GlobalSettings
+        }
+    }
+    ${GLOBAL_SETTINGS_FRAGMENT}
+`;
+
+const UPDATE_GLOBAL_SETTINGS = gql`
+    mutation UpdateGlobalSettings($input: UpdateGlobalSettingsInput!) {
+        updateGlobalSettings(input: $input) {
+            ...GlobalSettings
+            ... on ErrorResult {
+                code
+                message
+            }
+        }
+    }
+    ${GLOBAL_SETTINGS_FRAGMENT}
+`;

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

@@ -481,3 +481,33 @@ export const VARIANT_WITH_STOCK_FRAGMENT = gql`
         }
     }
 `;
+
+export const FULFILLMENT_FRAGMENT = gql`
+    fragment Fulfillment on Fulfillment {
+        id
+        state
+        nextStates
+        method
+        trackingCode
+        orderItems {
+            id
+        }
+    }
+`;
+
+export const CHANNEL_FRAGMENT = gql`
+    fragment Channel on Channel {
+        id
+        code
+        token
+        currencyCode
+        defaultLanguageCode
+        defaultShippingZone {
+            id
+        }
+        defaultTaxZone {
+            id
+        }
+        pricesIncludeTax
+    }
+`;

Dosya farkı çok büyük olduğundan ihmal edildi
+ 441 - 82
packages/core/e2e/graphql/generated-e2e-admin-types.ts


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

@@ -122,6 +122,8 @@ export type AuthenticationMethod = Node & {
     strategy: Scalars['String'];
 };
 
+export type AuthenticationResult = CurrentUser | InvalidCredentialsError;
+
 export type BooleanCustomFieldConfig = CustomField & {
     name: Scalars['String'];
     type: Scalars['String'];
@@ -838,12 +840,12 @@ export enum ErrorCode {
     PAYMENT_DECLINED_ERROR = 'PAYMENT_DECLINED_ERROR',
     ALREADY_LOGGED_IN_ERROR = 'ALREADY_LOGGED_IN_ERROR',
     EMAIL_ADDRESS_CONFLICT_ERROR = 'EMAIL_ADDRESS_CONFLICT_ERROR',
-    MISSING_PASSWORD_ERROR = 'MISSING_PASSWORD_ERROR',
+    INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS_ERROR',
     NATIVE_AUTH_STRATEGY_ERROR = 'NATIVE_AUTH_STRATEGY_ERROR',
+    MISSING_PASSWORD_ERROR = 'MISSING_PASSWORD_ERROR',
     VERIFICATION_TOKEN_INVALID_ERROR = 'VERIFICATION_TOKEN_INVALID_ERROR',
     VERIFICATION_TOKEN_EXPIRED_ERROR = 'VERIFICATION_TOKEN_EXPIRED_ERROR',
     PASSWORD_ALREADY_SET_ERROR = 'PASSWORD_ALREADY_SET_ERROR',
-    INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS_ERROR',
     IDENTIFIER_CHANGE_TOKEN_INVALID_ERROR = 'IDENTIFIER_CHANGE_TOKEN_INVALID_ERROR',
     IDENTIFIER_CHANGE_TOKEN_EXPIRED_ERROR = 'IDENTIFIER_CHANGE_TOKEN_EXPIRED_ERROR',
     PASSWORD_RESET_TOKEN_INVALID_ERROR = 'PASSWORD_RESET_TOKEN_INVALID_ERROR',
@@ -1040,7 +1042,7 @@ export type IntCustomFieldConfig = CustomField & {
     step?: Maybe<Scalars['Int']>;
 };
 
-/** Returned if the user credentials are not valid */
+/** Returned if the user authentication credentials are not valid */
 export type InvalidCredentialsError = ErrorResult & {
     code: ErrorCode;
     message: Scalars['String'];
@@ -1394,10 +1396,6 @@ export enum LogicalOperator {
     OR = 'OR',
 }
 
-export type LoginResult = {
-    user: CurrentUser;
-};
-
 /** Retured when attemting to register or verify a customer account without a password, when one is required. */
 export type MissingPasswordError = ErrorResult & {
     code: ErrorCode;
@@ -1441,11 +1439,11 @@ export type Mutation = {
      * Authenticates the user using the native authentication strategy. This mutation
      * is an alias for `authenticate({ native: { ... }})`
      */
-    login: LoginResult;
+    login: NativeAuthenticationResult;
     /** Authenticates the user using a named authentication strategy */
-    authenticate: LoginResult;
+    authenticate: AuthenticationResult;
     /** End the current authenticated session */
-    logout: Scalars['Boolean'];
+    logout: Success;
     /**
      * Register a Customer account with the given credentials. There are three possible registration flows:
      *
@@ -1622,6 +1620,8 @@ export type MutationResetPasswordArgs = {
     password: Scalars['String'];
 };
 
+export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
+
 export type NativeAuthInput = {
     username: Scalars['String'];
     password: Scalars['String'];

+ 43 - 27
packages/core/e2e/graphql/shared-definitions.ts

@@ -3,11 +3,13 @@ import gql from 'graphql-tag';
 import {
     ADMINISTRATOR_FRAGMENT,
     ASSET_FRAGMENT,
+    CHANNEL_FRAGMENT,
     COLLECTION_FRAGMENT,
     COUNTRY_FRAGMENT,
     CURRENT_USER_FRAGMENT,
     CUSTOMER_FRAGMENT,
     FACET_WITH_VALUES_FRAGMENT,
+    FULFILLMENT_FRAGMENT,
     ORDER_FRAGMENT,
     ORDER_WITH_LINES_FRAGMENT,
     PRODUCT_VARIANT_FRAGMENT,
@@ -199,9 +201,7 @@ export const GET_CUSTOMER = gql`
 export const ATTEMPT_LOGIN = gql`
     mutation AttemptLogin($username: String!, $password: String!, $rememberMe: Boolean) {
         login(username: $username, password: $password, rememberMe: $rememberMe) {
-            user {
-                ...CurrentUser
-            }
+            ...CurrentUser
         }
     }
     ${CURRENT_USER_FRAGMENT}
@@ -288,6 +288,10 @@ export const CREATE_PROMOTION = gql`
     mutation CreatePromotion($input: CreatePromotionInput!) {
         createPromotion(input: $input) {
             ...Promotion
+            ... on ErrorResult {
+                code
+                message
+            }
         }
     }
     ${PROMOTION_FRAGMENT}
@@ -304,20 +308,15 @@ export const ME = gql`
 export const CREATE_CHANNEL = gql`
     mutation CreateChannel($input: CreateChannelInput!) {
         createChannel(input: $input) {
-            id
-            code
-            token
-            currencyCode
-            defaultLanguageCode
-            defaultShippingZone {
-                id
-            }
-            defaultTaxZone {
-                id
+            ...Channel
+            ... on LanguageNotAvailableError {
+                errorCode: code
+                message
+                languageCode
             }
-            pricesIncludeTax
         }
     }
+    ${CHANNEL_FRAGMENT}
 `;
 
 export const DELETE_PRODUCT_VARIANT = gql`
@@ -374,12 +373,15 @@ export const DELETE_ASSET = gql`
 export const UPDATE_CHANNEL = gql`
     mutation UpdateChannel($input: UpdateChannelInput!) {
         updateChannel(input: $input) {
-            id
-            code
-            defaultLanguageCode
-            currencyCode
+            ...Channel
+            ... on LanguageNotAvailableError {
+                errorCode: code
+                message
+                languageCode
+            }
         }
     }
+    ${CHANNEL_FRAGMENT}
 `;
 
 export const GET_CUSTOMER_HISTORY = gql`
@@ -443,25 +445,31 @@ export const REMOVE_CUSTOMERS_FROM_GROUP = gql`
 
 export const CREATE_FULFILLMENT = gql`
     mutation CreateFulfillment($input: FulfillOrderInput!) {
-        fulfillOrder(input: $input) {
-            id
-            method
-            state
-            trackingCode
-            orderItems {
-                id
+        addFulfillmentToOrder(input: $input) {
+            ...Fulfillment
+            ... on ErrorResult {
+                code
+                message
             }
         }
     }
+    ${FULFILLMENT_FRAGMENT}
 `;
 
 export const TRANSIT_FULFILLMENT = gql`
     mutation TransitFulfillment($id: ID!, $state: String!) {
         transitionFulfillmentToState(id: $id, state: $state) {
-            id
-            state
+            ...Fulfillment
+            ... on FulfillmentStateTransitionError {
+                code
+                message
+                transitionError
+                fromState
+                toState
+            }
         }
     }
+    ${FULFILLMENT_FRAGMENT}
 `;
 
 export const GET_ORDER_FULFILLMENTS = gql`
@@ -531,6 +539,10 @@ export const CREATE_CUSTOMER = gql`
     mutation CreateCustomer($input: CreateCustomerInput!, $password: String) {
         createCustomer(input: $input, password: $password) {
             ...Customer
+            ... on ErrorResult {
+                code
+                message
+            }
         }
     }
     ${CUSTOMER_FRAGMENT}
@@ -540,6 +552,10 @@ export const UPDATE_CUSTOMER = gql`
     mutation UpdateCustomer($input: UpdateCustomerInput!) {
         updateCustomer(input: $input) {
             ...Customer
+            ... on ErrorResult {
+                code
+                message
+            }
         }
     }
     ${CUSTOMER_FRAGMENT}

+ 14 - 5
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -25,6 +25,7 @@ import {
     GetFacetList,
     GetPromoProducts,
     HistoryEntryType,
+    PromotionFragment,
     RemoveCustomersFromGroup,
 } from './graphql/generated-e2e-admin-types';
 import {
@@ -39,6 +40,7 @@ import {
     SetCustomerForOrder,
     TestOrderFragment,
     TestOrderFragmentFragment,
+    TestOrderWithPaymentsFragment,
     UpdatedOrderFragment,
 } from './graphql/generated-e2e-shop-types';
 import {
@@ -105,8 +107,8 @@ describe('Promotions applied to Orders', () => {
     describe('coupon codes', () => {
         const TEST_COUPON_CODE = 'TESTCOUPON';
         const EXPIRED_COUPON_CODE = 'EXPIRED';
-        let promoFreeWithCoupon: CreatePromotion.CreatePromotion;
-        let promoFreeWithExpiredCoupon: CreatePromotion.CreatePromotion;
+        let promoFreeWithCoupon: PromotionFragment;
+        let promoFreeWithExpiredCoupon: PromotionFragment;
 
         beforeAll(async () => {
             promoFreeWithCoupon = await createPromotion({
@@ -696,7 +698,10 @@ describe('Promotions applied to Orders', () => {
 
     describe('per-customer usage limit', () => {
         const TEST_COUPON_CODE = 'TESTCOUPON';
-        let promoWithUsageLimit: CreatePromotion.CreatePromotion;
+        const orderGuard: ErrorResultGuard<TestOrderWithPaymentsFragment> = createErrorResultGuard<
+            TestOrderWithPaymentsFragment
+        >(input => !!input.lines);
+        let promoWithUsageLimit: PromotionFragment;
 
         beforeAll(async () => {
             promoWithUsageLimit = await createPromotion({
@@ -758,6 +763,8 @@ describe('Promotions applied to Orders', () => {
 
                 await proceedToArrangingPayment(shopClient);
                 const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+                orderGuard.assertSuccess(order);
+
                 expect(order.state).toBe('PaymentSettled');
                 expect(order.active).toBe(false);
                 orderCode = order.code;
@@ -831,6 +838,8 @@ describe('Promotions applied to Orders', () => {
 
                 await proceedToArrangingPayment(shopClient);
                 const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+                orderGuard.assertSuccess(order);
+
                 expect(order.state).toBe('PaymentSettled');
                 expect(order.active).toBe(false);
             });
@@ -899,14 +908,14 @@ describe('Promotions applied to Orders', () => {
         await deletePromotion(deletedPromotion.id);
     }
 
-    async function createPromotion(input: CreatePromotionInput): Promise<CreatePromotion.CreatePromotion> {
+    async function createPromotion(input: CreatePromotionInput): Promise<PromotionFragment> {
         const result = await adminClient.query<CreatePromotion.Mutation, CreatePromotion.Variables>(
             CREATE_PROMOTION,
             {
                 input,
             },
         );
-        return result.createPromotion;
+        return result.createPromotion as PromotionFragment;
     }
 
     function getVariantBySlug(

+ 363 - 241
packages/core/e2e/order.e2e-spec.ts

@@ -1,6 +1,11 @@
 /* tslint:disable:no-non-null-assertion */
 import { pick } from '@vendure/common/lib/pick';
-import { createTestEnvironment, SimpleGraphQLClient } from '@vendure/testing';
+import {
+    createErrorResultGuard,
+    createTestEnvironment,
+    ErrorResultGuard,
+    SimpleGraphQLClient,
+} from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
@@ -13,11 +18,15 @@ import {
     singleStageRefundablePaymentMethod,
     twoStagePaymentMethod,
 } from './fixtures/test-payment-methods';
+import { FULFILLMENT_FRAGMENT } from './graphql/fragments';
 import {
     AddNoteToOrder,
+    CanceledOrderFragment,
     CancelOrder,
     CreateFulfillment,
     DeleteOrderNote,
+    ErrorCode,
+    FulfillmentFragment,
     GetCustomerList,
     GetOrder,
     GetOrderFulfillmentItems,
@@ -28,6 +37,8 @@ import {
     GetProductWithVariants,
     GetStockMovement,
     HistoryEntryType,
+    PaymentFragment,
+    RefundFragment,
     RefundOrder,
     SettlePayment,
     SettleRefund,
@@ -37,7 +48,13 @@ import {
     UpdateOrderNote,
     UpdateProductVariants,
 } from './graphql/generated-e2e-admin-types';
-import { AddItemToOrder, DeletionResult, GetActiveOrder } from './graphql/generated-e2e-shop-types';
+import {
+    AddItemToOrder,
+    DeletionResult,
+    GetActiveOrder,
+    TestOrderFragmentFragment,
+    UpdatedOrder,
+} from './graphql/generated-e2e-shop-types';
 import {
     CREATE_FULFILLMENT,
     GET_CUSTOMER_LIST,
@@ -67,6 +84,19 @@ describe('Orders resolver', () => {
     let customers: GetCustomerList.Items[];
     const password = 'test';
 
+    const orderGuard: ErrorResultGuard<
+        TestOrderFragmentFragment | CanceledOrderFragment
+    > = createErrorResultGuard<TestOrderFragmentFragment | CanceledOrderFragment>(input => !!input.lines);
+    const paymentGuard: ErrorResultGuard<PaymentFragment> = createErrorResultGuard<PaymentFragment>(
+        input => !!input.state,
+    );
+    const fulfillmentGuard: ErrorResultGuard<FulfillmentFragment> = createErrorResultGuard<
+        FulfillmentFragment
+    >(input => !!input.method);
+    const refundGuard: ErrorResultGuard<RefundFragment> = createErrorResultGuard<RefundFragment>(
+        input => !!input.items,
+    );
+
     beforeAll(async () => {
         await server.init({
             initialData,
@@ -133,6 +163,7 @@ describe('Orders resolver', () => {
             await shopClient.asUserWithCredentials(customers[0].emailAddress, password);
             await proceedToArrangingPayment(shopClient);
             const order = await addPaymentToOrder(shopClient, failsToSettlePaymentMethod);
+            orderGuard.assertSuccess(order);
 
             expect(order.state).toBe('PaymentAuthorized');
 
@@ -143,9 +174,11 @@ describe('Orders resolver', () => {
             >(SETTLE_PAYMENT, {
                 id: payment.id,
             });
+            paymentGuard.assertErrorResult(settlePayment);
 
-            expect(settlePayment!.id).toBe(payment.id);
-            expect(settlePayment!.state).toBe('Authorized');
+            expect(settlePayment.message).toBe('Settling the payment failed');
+            expect(settlePayment.code).toBe(ErrorCode.SETTLE_PAYMENT_ERROR);
+            expect((settlePayment as any).paymentErrorMessage).toBe('Something went horribly wrong');
 
             const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: order.id,
@@ -159,6 +192,7 @@ describe('Orders resolver', () => {
             await shopClient.asUserWithCredentials(customers[1].emailAddress, password);
             await proceedToArrangingPayment(shopClient);
             const order = await addPaymentToOrder(shopClient, twoStagePaymentMethod);
+            orderGuard.assertSuccess(order);
 
             expect(order.state).toBe('PaymentAuthorized');
             expect(onTransitionSpy).toHaveBeenCalledTimes(1);
@@ -172,6 +206,7 @@ describe('Orders resolver', () => {
             >(SETTLE_PAYMENT, {
                 id: payment.id,
             });
+            paymentGuard.assertSuccess(settlePayment);
 
             expect(settlePayment!.id).toBe(payment.id);
             expect(settlePayment!.state).toBe('Settled');
@@ -240,63 +275,45 @@ describe('Orders resolver', () => {
     });
 
     describe('fulfillment', () => {
-        it(
-            'throws if Order is not in "PaymentSettled" state',
-            assertThrowsWithMessage(async () => {
-                const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                    id: 'T_1',
-                });
-                expect(order!.state).toBe('PaymentAuthorized');
+        it('return error result if lines is empty', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: 'T_2',
+            });
+            expect(order!.state).toBe('PaymentSettled');
+            const { addFulfillmentToOrder } = await adminClient.query<
+                CreateFulfillment.Mutation,
+                CreateFulfillment.Variables
+            >(CREATE_FULFILLMENT, {
+                input: {
+                    lines: [],
+                    method: 'Test',
+                },
+            });
+            fulfillmentGuard.assertErrorResult(addFulfillmentToOrder);
 
-                await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
-                    CREATE_FULFILLMENT,
-                    {
-                        input: {
-                            lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
-                            method: 'Test',
-                        },
-                    },
-                );
-            }, 'One or more OrderItems belong to an Order which is in an invalid state'),
-        );
+            expect(addFulfillmentToOrder.message).toBe('At least one OrderLine must be specified');
+            expect(addFulfillmentToOrder.code).toBe(ErrorCode.EMPTY_ORDER_LINE_SELECTION_ERROR);
+        });
 
-        it(
-            'throws if lines is empty',
-            assertThrowsWithMessage(async () => {
-                const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                    id: 'T_2',
-                });
-                expect(order!.state).toBe('PaymentSettled');
-                await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
-                    CREATE_FULFILLMENT,
-                    {
-                        input: {
-                            lines: [],
-                            method: 'Test',
-                        },
-                    },
-                );
-            }, 'Nothing to fulfill'),
-        );
+        it('returns error result if all quantities are zero', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: 'T_2',
+            });
+            expect(order!.state).toBe('PaymentSettled');
+            const { addFulfillmentToOrder } = await adminClient.query<
+                CreateFulfillment.Mutation,
+                CreateFulfillment.Variables
+            >(CREATE_FULFILLMENT, {
+                input: {
+                    lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 0 })),
+                    method: 'Test',
+                },
+            });
+            fulfillmentGuard.assertErrorResult(addFulfillmentToOrder);
 
-        it(
-            'throws if all quantities are zero',
-            assertThrowsWithMessage(async () => {
-                const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                    id: 'T_2',
-                });
-                expect(order!.state).toBe('PaymentSettled');
-                await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
-                    CREATE_FULFILLMENT,
-                    {
-                        input: {
-                            lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 0 })),
-                            method: 'Test',
-                        },
-                    },
-                );
-            }, 'Nothing to fulfill'),
-        );
+            expect(addFulfillmentToOrder.message).toBe('At least one OrderLine must be specified');
+            expect(addFulfillmentToOrder.code).toBe(ErrorCode.EMPTY_ORDER_LINE_SELECTION_ERROR);
+        });
 
         it('creates the first fulfillment', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
@@ -305,7 +322,7 @@ describe('Orders resolver', () => {
             expect(order!.state).toBe('PaymentSettled');
             const lines = order!.lines;
 
-            const { fulfillOrder } = await adminClient.query<
+            const { addFulfillmentToOrder } = await adminClient.query<
                 CreateFulfillment.Mutation,
                 CreateFulfillment.Variables
             >(CREATE_FULFILLMENT, {
@@ -315,21 +332,22 @@ describe('Orders resolver', () => {
                     trackingCode: '111',
                 },
             });
+            fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
 
-            expect(fulfillOrder!.id).toBe('T_1');
-            expect(fulfillOrder!.method).toBe('Test1');
-            expect(fulfillOrder!.trackingCode).toBe('111');
-            expect(fulfillOrder!.state).toBe('Pending');
-            expect(fulfillOrder!.orderItems).toEqual([{ id: lines[0].items[0].id }]);
+            expect(addFulfillmentToOrder.id).toBe('T_1');
+            expect(addFulfillmentToOrder.method).toBe('Test1');
+            expect(addFulfillmentToOrder.trackingCode).toBe('111');
+            expect(addFulfillmentToOrder.state).toBe('Pending');
+            expect(addFulfillmentToOrder.orderItems).toEqual([{ id: lines[0].items[0].id }]);
 
             const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
 
-            expect(result.order!.lines[0].items[0].fulfillment!.id).toBe(fulfillOrder!.id);
+            expect(result.order!.lines[0].items[0].fulfillment!.id).toBe(addFulfillmentToOrder!.id);
             expect(
                 result.order!.lines[1].items.filter(
-                    i => i.fulfillment && i.fulfillment.id === fulfillOrder.id,
+                    i => i.fulfillment && i.fulfillment.id === addFulfillmentToOrder.id,
                 ).length,
             ).toBe(0);
             expect(result.order!.lines[1].items.filter(i => i.fulfillment == null).length).toBe(3);
@@ -346,7 +364,7 @@ describe('Orders resolver', () => {
                     return items.length > 0 ? true : false;
                 }) || [];
 
-            const { fulfillOrder } = await adminClient.query<
+            const { addFulfillmentToOrder } = await adminClient.query<
                 CreateFulfillment.Mutation,
                 CreateFulfillment.Variables
             >(CREATE_FULFILLMENT, {
@@ -359,93 +377,109 @@ describe('Orders resolver', () => {
                     trackingCode: '222',
                 },
             });
+            fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
 
-            expect(fulfillOrder!.id).toBe('T_2');
-            expect(fulfillOrder!.method).toBe('Test2');
-            expect(fulfillOrder!.trackingCode).toBe('222');
-            expect(fulfillOrder!.state).toBe('Pending');
+            expect(addFulfillmentToOrder.id).toBe('T_2');
+            expect(addFulfillmentToOrder.method).toBe('Test2');
+            expect(addFulfillmentToOrder.trackingCode).toBe('222');
+            expect(addFulfillmentToOrder.state).toBe('Pending');
         });
 
-        it(
-            'throws if an OrderItem already part of a Fulfillment',
-            assertThrowsWithMessage(async () => {
-                const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                    id: 'T_2',
-                });
-                await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
-                    CREATE_FULFILLMENT,
-                    {
-                        input: {
-                            method: 'Test',
-                            lines: [
-                                {
-                                    orderLineId: order!.lines[0].id,
-                                    quantity: 1,
-                                },
-                            ],
+        it('returns error result if an OrderItem already part of a Fulfillment', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: 'T_2',
+            });
+            const { addFulfillmentToOrder } = await adminClient.query<
+                CreateFulfillment.Mutation,
+                CreateFulfillment.Variables
+            >(CREATE_FULFILLMENT, {
+                input: {
+                    method: 'Test',
+                    lines: [
+                        {
+                            orderLineId: order!.lines[0].id,
+                            quantity: 1,
                         },
-                    },
-                );
-            }, 'One or more OrderItems have already been fulfilled'),
-        );
+                    ],
+                },
+            });
+            fulfillmentGuard.assertErrorResult(addFulfillmentToOrder);
+
+            expect(addFulfillmentToOrder.message).toBe(
+                'One or more OrderItems are already part of a Fulfillment',
+            );
+            expect(addFulfillmentToOrder.code).toBe(ErrorCode.ITEMS_ALREADY_FULFILLED_ERROR);
+        });
+
         it('transits the first fulfillment from created to Shipped and automatically change the order state to PartiallyShipped', async () => {
-            const fulfillment = await adminClient.query<
+            const { transitionFulfillmentToState } = await adminClient.query<
                 TransitFulfillment.Mutation,
                 TransitFulfillment.Variables
             >(TRANSIT_FULFILLMENT, {
                 id: 'T_1',
                 state: 'Shipped',
             });
-            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_1');
-            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Shipped');
+            fulfillmentGuard.assertSuccess(transitionFulfillmentToState);
+
+            expect(transitionFulfillmentToState.id).toBe('T_1');
+            expect(transitionFulfillmentToState.state).toBe('Shipped');
 
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
             expect(order?.state).toBe('PartiallyShipped');
         });
+
         it('transits the second fulfillment from created to Shipped and automatically change the order state to Shipped', async () => {
-            const fulfillment = await adminClient.query<
+            const { transitionFulfillmentToState } = await adminClient.query<
                 TransitFulfillment.Mutation,
                 TransitFulfillment.Variables
             >(TRANSIT_FULFILLMENT, {
                 id: 'T_2',
                 state: 'Shipped',
             });
-            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_2');
-            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Shipped');
+            fulfillmentGuard.assertSuccess(transitionFulfillmentToState);
+
+            expect(transitionFulfillmentToState.id).toBe('T_2');
+            expect(transitionFulfillmentToState.state).toBe('Shipped');
 
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
             expect(order?.state).toBe('Shipped');
         });
+
         it('transits the first fulfillment from Shipped to Delivered and change the order state to PartiallyDelivered', async () => {
-            const fulfillment = await adminClient.query<
+            const { transitionFulfillmentToState } = await adminClient.query<
                 TransitFulfillment.Mutation,
                 TransitFulfillment.Variables
             >(TRANSIT_FULFILLMENT, {
                 id: 'T_1',
                 state: 'Delivered',
             });
-            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_1');
-            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Delivered');
+            fulfillmentGuard.assertSuccess(transitionFulfillmentToState);
+
+            expect(transitionFulfillmentToState.id).toBe('T_1');
+            expect(transitionFulfillmentToState.state).toBe('Delivered');
 
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
             expect(order?.state).toBe('PartiallyDelivered');
         });
+
         it('transits the second fulfillment from Shipped to Delivered and change the order state to Delivered', async () => {
-            const fulfillment = await adminClient.query<
+            const { transitionFulfillmentToState } = await adminClient.query<
                 TransitFulfillment.Mutation,
                 TransitFulfillment.Variables
             >(TRANSIT_FULFILLMENT, {
                 id: 'T_2',
                 state: 'Delivered',
             });
-            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_2');
-            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Delivered');
+            fulfillmentGuard.assertSuccess(transitionFulfillmentToState);
+
+            expect(transitionFulfillmentToState.id).toBe('T_2');
+            expect(transitionFulfillmentToState.state).toBe('Delivered');
 
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
@@ -642,6 +676,8 @@ describe('Orders resolver', () => {
             );
             await proceedToArrangingPayment(shopClient);
             const order = await addPaymentToOrder(shopClient, failsToSettlePaymentMethod);
+            orderGuard.assertSuccess(order);
+
             expect(order.state).toBe('PaymentAuthorized');
 
             const result1 = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
@@ -665,6 +701,8 @@ describe('Orders resolver', () => {
                     },
                 },
             );
+            orderGuard.assertSuccess(cancelOrder);
+
             expect(
                 cancelOrder.lines.map(l =>
                     l.items.map(pick(['id', 'cancelled'])).sort((a, b) => (a.id > b.id ? 1 : -1)),
@@ -729,68 +767,91 @@ describe('Orders resolver', () => {
             productVariantId = result.productVariantId;
         });
 
-        it(
-            'cannot cancel from AddingItems state',
-            assertThrowsWithMessage(async () => {
-                const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                    id: orderId,
-                });
-                expect(order!.state).toBe('AddingItems');
-                await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
+        it('cannot cancel from AddingItems state', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            expect(order!.state).toBe('AddingItems');
+
+            const { cancelOrder } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(
+                CANCEL_ORDER,
+                {
                     input: {
                         orderId,
                         lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
                     },
-                });
-            }, 'Cannot cancel OrderLines from an Order in the "AddingItems" state'),
-        );
+                },
+            );
+            orderGuard.assertErrorResult(cancelOrder);
 
-        it(
-            'cannot cancel from ArrangingPayment state',
-            assertThrowsWithMessage(async () => {
-                await proceedToArrangingPayment(shopClient);
-                const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                    id: orderId,
-                });
-                expect(order!.state).toBe('ArrangingPayment');
-                await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
+            expect(cancelOrder.message).toBe(
+                'Cannot cancel OrderLines from an Order in the "AddingItems" state',
+            );
+            expect(cancelOrder.code).toBe(ErrorCode.CANCEL_ACTIVE_ORDER_ERROR);
+        });
+
+        it('cannot cancel from ArrangingPayment state', async () => {
+            await proceedToArrangingPayment(shopClient);
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            expect(order!.state).toBe('ArrangingPayment');
+            const { cancelOrder } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(
+                CANCEL_ORDER,
+                {
                     input: {
                         orderId,
                         lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
                     },
-                });
-            }, 'Cannot cancel OrderLines from an Order in the "ArrangingPayment" state'),
-        );
+                },
+            );
+            orderGuard.assertErrorResult(cancelOrder);
 
-        it(
-            'throws if lines are empty',
-            assertThrowsWithMessage(async () => {
-                const order = await addPaymentToOrder(shopClient, twoStagePaymentMethod);
-                expect(order.state).toBe('PaymentAuthorized');
+            expect(cancelOrder.message).toBe(
+                'Cannot cancel OrderLines from an Order in the "ArrangingPayment" state',
+            );
+            expect(cancelOrder.code).toBe(ErrorCode.CANCEL_ACTIVE_ORDER_ERROR);
+        });
 
-                await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
+        it('returns error result if lines are empty', async () => {
+            const order = await addPaymentToOrder(shopClient, twoStagePaymentMethod);
+            orderGuard.assertSuccess(order);
+
+            expect(order.state).toBe('PaymentAuthorized');
+
+            const { cancelOrder } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(
+                CANCEL_ORDER,
+                {
                     input: {
                         orderId,
                         lines: [],
                     },
-                });
-            }, 'Nothing to cancel'),
-        );
+                },
+            );
+            orderGuard.assertErrorResult(cancelOrder);
 
-        it(
-            'throws if all quantities zero',
-            assertThrowsWithMessage(async () => {
-                const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                    id: orderId,
-                });
-                await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
+            expect(cancelOrder.message).toBe('At least one OrderLine must be specified');
+            expect(cancelOrder.code).toBe(ErrorCode.EMPTY_ORDER_LINE_SELECTION_ERROR);
+        });
+
+        it('returns error result if all quantities zero', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { cancelOrder } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(
+                CANCEL_ORDER,
+                {
                     input: {
                         orderId,
                         lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 0 })),
                     },
-                });
-            }, 'Nothing to cancel'),
-        );
+                },
+            );
+            orderGuard.assertErrorResult(cancelOrder);
+
+            expect(cancelOrder.message).toBe('At least one OrderLine must be specified');
+            expect(cancelOrder.code).toBe(ErrorCode.EMPTY_ORDER_LINE_SELECTION_ERROR);
+        });
 
         it('partial cancellation', async () => {
             const result1 = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
@@ -823,6 +884,7 @@ describe('Orders resolver', () => {
                     },
                 },
             );
+            orderGuard.assertSuccess(cancelOrder);
 
             expect(cancelOrder.lines[0].quantity).toBe(1);
             expect(cancelOrder.lines[0].items.sort((a, b) => (a.id < b.id ? -1 : 1))).toEqual([
@@ -855,6 +917,27 @@ describe('Orders resolver', () => {
             ]);
         });
 
+        it('returns error result if attempting to cancel already cancelled item', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { cancelOrder } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(
+                CANCEL_ORDER,
+                {
+                    input: {
+                        orderId,
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 2 })),
+                    },
+                },
+            );
+            orderGuard.assertErrorResult(cancelOrder);
+
+            expect(cancelOrder.message).toBe(
+                'The specified quantity is greater than the available OrderItems',
+            );
+            expect(cancelOrder.code).toBe(ErrorCode.QUANTITY_TOO_GREAT_ERROR);
+        });
+
         it('complete cancellation', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: orderId,
@@ -968,50 +1051,61 @@ describe('Orders resolver', () => {
             productVariantId = result.productVariantId;
         });
 
-        it(
-            'cannot refund from PaymentAuthorized state',
-            assertThrowsWithMessage(async () => {
-                await proceedToArrangingPayment(shopClient);
-                const order = await addPaymentToOrder(shopClient, twoStagePaymentMethod);
-                expect(order.state).toBe('PaymentAuthorized');
-                paymentId = order.payments![0].id;
+        it('cannot refund from PaymentAuthorized state', async () => {
+            await proceedToArrangingPayment(shopClient);
+            const order = await addPaymentToOrder(shopClient, twoStagePaymentMethod);
+            orderGuard.assertSuccess(order);
 
-                await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(REFUND_ORDER, {
+            expect(order.state).toBe('PaymentAuthorized');
+            paymentId = order.payments![0].id;
+
+            const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(
+                REFUND_ORDER,
+                {
                     input: {
                         lines: order.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
                         shipping: 0,
                         adjustment: 0,
                         paymentId,
                     },
-                });
-            }, 'Cannot refund an Order in the "PaymentAuthorized" state'),
-        );
+                },
+            );
+            refundGuard.assertErrorResult(refundOrder);
 
-        it(
-            'throws if no lines and no shipping',
-            assertThrowsWithMessage(async () => {
-                const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                    id: orderId,
-                });
-                const { settlePayment } = await adminClient.query<
-                    SettlePayment.Mutation,
-                    SettlePayment.Variables
-                >(SETTLE_PAYMENT, {
-                    id: order!.payments![0].id,
-                });
+            expect(refundOrder.message).toBe('Cannot refund an Order in the "PaymentAuthorized" state');
+            expect(refundOrder.code).toBe(ErrorCode.REFUND_ORDER_STATE_ERROR);
+        });
 
-                expect(settlePayment!.state).toBe('Settled');
+        it('returns error result if no lines and no shipping', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { settlePayment } = await adminClient.query<
+                SettlePayment.Mutation,
+                SettlePayment.Variables
+            >(SETTLE_PAYMENT, {
+                id: order!.payments![0].id,
+            });
+            paymentGuard.assertSuccess(settlePayment);
 
-                await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(REFUND_ORDER, {
+            expect(settlePayment!.state).toBe('Settled');
+
+            const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(
+                REFUND_ORDER,
+                {
                     input: {
                         lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 0 })),
                         shipping: 0,
                         adjustment: 0,
                         paymentId,
                     },
-                });
-            }, 'Nothing to refund'),
-        );
+                },
+            );
+            refundGuard.assertErrorResult(refundOrder);
+
+            expect(refundOrder.message).toBe('Nothing to refund');
+            expect(refundOrder.code).toBe(ErrorCode.NOTHING_TO_REFUND_ERROR);
+        });
 
         it(
             'throws if paymentId not valid',
@@ -1030,28 +1124,29 @@ describe('Orders resolver', () => {
                         },
                     },
                 );
-            }, "No Payment with the id '999' could be found"),
+            }, `No Payment with the id '999' could be found`),
         );
 
-        it(
-            'throws if payment and order lines do not belong to the same Order',
-            assertThrowsWithMessage(async () => {
-                const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                    id: orderId,
-                });
-                const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(
-                    REFUND_ORDER,
-                    {
-                        input: {
-                            lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
-                            shipping: 100,
-                            adjustment: 0,
-                            paymentId: 'T_1',
-                        },
+        it('returns error result if payment and order lines do not belong to the same Order', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(
+                REFUND_ORDER,
+                {
+                    input: {
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
+                        shipping: 100,
+                        adjustment: 0,
+                        paymentId: 'T_1',
                     },
-                );
-            }, 'The Payment and OrderLines do not belong to the same Order'),
-        );
+                },
+            );
+            refundGuard.assertErrorResult(refundOrder);
+
+            expect(refundOrder.message).toBe('The Payment and OrderLines do not belong to the same Order');
+            expect(refundOrder.code).toBe(ErrorCode.PAYMENT_ORDER_MISMATCH_ERROR);
+        });
 
         it('creates a Refund to be manually settled', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
@@ -1069,6 +1164,7 @@ describe('Orders resolver', () => {
                     },
                 },
             );
+            refundGuard.assertSuccess(refundOrder);
 
             expect(refundOrder.shipping).toBe(order!.shipping);
             expect(refundOrder.items).toBe(order!.subTotal);
@@ -1078,25 +1174,26 @@ describe('Orders resolver', () => {
             refundId = refundOrder.id;
         });
 
-        it(
-            'throws if attempting to refund the same item more than once',
-            assertThrowsWithMessage(async () => {
-                const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                    id: orderId,
-                });
-                const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(
-                    REFUND_ORDER,
-                    {
-                        input: {
-                            lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
-                            shipping: order!.shipping,
-                            adjustment: 0,
-                            paymentId,
-                        },
+        it('returns error result if attempting to refund the same item more than once', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(
+                REFUND_ORDER,
+                {
+                    input: {
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
+                        shipping: order!.shipping,
+                        adjustment: 0,
+                        paymentId,
                     },
-                );
-            }, 'Cannot refund an OrderItem which has already been refunded'),
-        );
+                },
+            );
+            refundGuard.assertErrorResult(refundOrder);
+
+            expect(refundOrder.message).toBe('Cannot refund an OrderItem which has already been refunded');
+            expect(refundOrder.code).toBe(ErrorCode.ALREADY_REFUNDED_ERROR);
+        });
 
         it('manually settle a Refund', async () => {
             const { settleRefund } = await adminClient.query<SettleRefund.Mutation, SettleRefund.Variables>(
@@ -1108,6 +1205,7 @@ describe('Orders resolver', () => {
                     },
                 },
             );
+            refundGuard.assertSuccess(settleRefund);
 
             expect(settleRefund.state).toBe('Settled');
             expect(settleRefund.transactionId).toBe('aaabbb');
@@ -1356,18 +1454,28 @@ async function createTestOrder(
             quantity: 2,
         },
     );
-    const orderId = addItemToOrder!.id;
+    const orderId = (addItemToOrder as UpdatedOrder.Fragment).id;
     return { product, productVariantId, orderId };
 }
 
 export const SETTLE_PAYMENT = gql`
     mutation SettlePayment($id: ID!) {
         settlePayment(id: $id) {
-            id
-            state
-            metadata
+            ...Payment
+            ... on ErrorResult {
+                code
+                message
+            }
+            ... on SettlePaymentError {
+                paymentErrorMessage
+            }
         }
     }
+    fragment Payment on Payment {
+        id
+        state
+        metadata
+    }
 `;
 
 export const GET_ORDER_LIST_FULFILLMENTS = gql`
@@ -1393,57 +1501,71 @@ export const GET_ORDER_FULFILLMENT_ITEMS = gql`
             id
             state
             fulfillments {
-                id
-                state
-                orderItems {
-                    id
-                }
+                ...Fulfillment
             }
         }
     }
+    ${FULFILLMENT_FRAGMENT}
 `;
 
 export const CANCEL_ORDER = gql`
     mutation CancelOrder($input: CancelOrderInput!) {
         cancelOrder(input: $input) {
-            id
-            lines {
-                quantity
-                items {
-                    id
-                    cancelled
-                }
+            ...CanceledOrder
+            ... on ErrorResult {
+                code
+                message
+            }
+        }
+    }
+    fragment CanceledOrder on Order {
+        id
+        lines {
+            quantity
+            items {
+                id
+                cancelled
             }
         }
     }
 `;
 
+const REFUND_FRAGMENT = gql`
+    fragment Refund on Refund {
+        id
+        state
+        items
+        transactionId
+        shipping
+        total
+        metadata
+    }
+`;
+
 export const REFUND_ORDER = gql`
     mutation RefundOrder($input: RefundOrderInput!) {
         refundOrder(input: $input) {
-            id
-            state
-            items
-            transactionId
-            shipping
-            total
-            metadata
+            ...Refund
+            ... on ErrorResult {
+                code
+                message
+            }
         }
     }
+    ${REFUND_FRAGMENT}
 `;
 
 export const SETTLE_REFUND = gql`
     mutation SettleRefund($input: SettleRefundInput!) {
         settleRefund(input: $input) {
-            id
-            state
-            items
-            transactionId
-            shipping
-            total
-            metadata
+            ...Refund
+            ... on ErrorResult {
+                code
+                message
+            }
         }
     }
+    ${REFUND_FRAGMENT}
 `;
 
 export const GET_ORDER_HISTORY = gql`

+ 49 - 32
packages/core/e2e/product.e2e-spec.ts

@@ -1,7 +1,7 @@
 import { omit } from '@vendure/common/lib/omit';
 import { pick } from '@vendure/common/lib/pick';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
-import { createTestEnvironment } from '@vendure/testing';
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
@@ -15,6 +15,7 @@ import {
     DeleteProduct,
     DeleteProductVariant,
     DeletionResult,
+    ErrorCode,
     GetAssetList,
     GetOptionGroup,
     GetProductList,
@@ -23,6 +24,7 @@ import {
     GetProductWithVariants,
     LanguageCode,
     ProductVariantFragment,
+    ProductWithOptionsFragment,
     ProductWithVariants,
     RemoveOptionGroupFromProduct,
     SortOrder,
@@ -49,6 +51,10 @@ import { sortById } from './utils/test-order-utils';
 describe('Product resolver', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
 
+    const removeOptionGuard: ErrorResultGuard<ProductWithOptionsFragment> = createErrorResultGuard<
+        ProductWithOptionsFragment
+    >(input => !!input.optionGroups);
+
     beforeAll(async () => {
         await server.init({
             initialData,
@@ -682,31 +688,36 @@ describe('Product resolver', () => {
             });
             expect(addOptionGroupToProduct.optionGroups.length).toBe(1);
 
-            const result = await adminClient.query<
+            const { removeOptionGroupFromProduct } = await adminClient.query<
                 RemoveOptionGroupFromProduct.Mutation,
                 RemoveOptionGroupFromProduct.Variables
             >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
                 optionGroupId: 'T_1',
                 productId: newProductWithAssets.id,
             });
-            expect(result.removeOptionGroupFromProduct.id).toBe(newProductWithAssets.id);
-            expect(result.removeOptionGroupFromProduct.optionGroups.length).toBe(0);
+            removeOptionGuard.assertSuccess(removeOptionGroupFromProduct);
+
+            expect(removeOptionGroupFromProduct.id).toBe(newProductWithAssets.id);
+            expect(removeOptionGroupFromProduct.optionGroups.length).toBe(0);
         });
 
-        it(
-            'removeOptionGroupFromProduct errors if the optionGroup is being used by variants',
-            assertThrowsWithMessage(
-                () =>
-                    adminClient.query<
-                        RemoveOptionGroupFromProduct.Mutation,
-                        RemoveOptionGroupFromProduct.Variables
-                    >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
-                        optionGroupId: 'T_3',
-                        productId: 'T_2',
-                    }),
+        it('removeOptionGroupFromProduct return error result if the optionGroup is being used by variants', async () => {
+            const { removeOptionGroupFromProduct } = await adminClient.query<
+                RemoveOptionGroupFromProduct.Mutation,
+                RemoveOptionGroupFromProduct.Variables
+            >(REMOVE_OPTION_GROUP_FROM_PRODUCT, {
+                optionGroupId: 'T_3',
+                productId: 'T_2',
+            });
+            removeOptionGuard.assertErrorResult(removeOptionGroupFromProduct);
+
+            expect(removeOptionGroupFromProduct.message).toBe(
                 `Cannot remove ProductOptionGroup "curvy-monitor-monitor-size" as it is used by 2 ProductVariants`,
-            ),
-        );
+            );
+            expect(removeOptionGroupFromProduct.code).toBe(ErrorCode.PRODUCT_OPTION_IN_USE_ERROR);
+            expect(removeOptionGroupFromProduct.optionGroupCode).toBe('curvy-monitor-monitor-size');
+            expect(removeOptionGroupFromProduct.productVariantCount).toBe(2);
+        });
 
         it(
             'removeOptionGroupFromProduct errors with an invalid productId',
@@ -1157,36 +1168,42 @@ describe('Product resolver', () => {
     });
 });
 
-export const ADD_OPTION_GROUP_TO_PRODUCT = gql`
-    mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
-        addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
+const PRODUCT_WITH_OPTIONS_FRAGMENT = gql`
+    fragment ProductWithOptions on Product {
+        id
+        optionGroups {
             id
-            optionGroups {
+            code
+            options {
                 id
                 code
-                options {
-                    id
-                    code
-                }
             }
         }
     }
 `;
 
+export const ADD_OPTION_GROUP_TO_PRODUCT = gql`
+    mutation AddOptionGroupToProduct($productId: ID!, $optionGroupId: ID!) {
+        addOptionGroupToProduct(productId: $productId, optionGroupId: $optionGroupId) {
+            ...ProductWithOptions
+        }
+    }
+    ${PRODUCT_WITH_OPTIONS_FRAGMENT}
+`;
+
 export const REMOVE_OPTION_GROUP_FROM_PRODUCT = gql`
     mutation RemoveOptionGroupFromProduct($productId: ID!, $optionGroupId: ID!) {
         removeOptionGroupFromProduct(productId: $productId, optionGroupId: $optionGroupId) {
-            id
-            optionGroups {
-                id
+            ...ProductWithOptions
+            ... on ProductOptionInUseError {
                 code
-                options {
-                    id
-                    code
-                }
+                message
+                optionGroupCode
+                productVariantCount
             }
         }
     }
+    ${PRODUCT_WITH_OPTIONS_FRAGMENT}
 `;
 
 export const GET_OPTION_GROUP = gql`

+ 113 - 87
packages/core/e2e/promotion.e2e-spec.ts

@@ -1,22 +1,24 @@
 import { pick } from '@vendure/common/lib/pick';
 import { PromotionAction, PromotionCondition, PromotionOrderAction } from '@vendure/core';
-import { createTestEnvironment } from '@vendure/testing';
+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 { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import { PROMOTION_FRAGMENT } from './graphql/fragments';
 import {
     CreatePromotion,
     DeletePromotion,
     DeletionResult,
+    ErrorCode,
     GetAdjustmentOperations,
     GetPromotion,
     GetPromotionList,
     LanguageCode,
     Promotion,
+    PromotionFragment,
     UpdatePromotion,
 } from './graphql/generated-e2e-admin-types';
 import { CREATE_PROMOTION } from './graphql/shared-definitions';
@@ -48,6 +50,10 @@ describe('Promotion resolver', () => {
     ];
     let promotion: Promotion.Fragment;
 
+    const promotionGuard: ErrorResultGuard<PromotionFragment> = createErrorResultGuard<PromotionFragment>(
+        input => !!input.couponCode,
+    );
+
     beforeAll(async () => {
         await server.init({
             initialData,
@@ -62,100 +68,116 @@ describe('Promotion resolver', () => {
     });
 
     it('createPromotion', async () => {
-        const result = await adminClient.query<CreatePromotion.Mutation, CreatePromotion.Variables>(
-            CREATE_PROMOTION,
-            {
-                input: {
-                    name: 'test promotion',
-                    enabled: true,
-                    couponCode: 'TEST123',
-                    startsAt: new Date('2019-10-30T00:00:00.000Z'),
-                    endsAt: new Date('2019-12-01T00:00:00.000Z'),
-                    conditions: [
-                        {
-                            code: promoCondition.code,
-                            arguments: [{ name: 'arg', value: '500' }],
-                        },
-                    ],
-                    actions: [
-                        {
-                            code: promoAction.code,
-                            arguments: [
-                                {
-                                    name: 'facetValueIds',
-                                    value: '["T_1"]',
-                                },
-                            ],
-                        },
-                    ],
-                },
+        const { createPromotion } = await adminClient.query<
+            CreatePromotion.Mutation,
+            CreatePromotion.Variables
+        >(CREATE_PROMOTION, {
+            input: {
+                name: 'test promotion',
+                enabled: true,
+                couponCode: 'TEST123',
+                startsAt: new Date('2019-10-30T00:00:00.000Z'),
+                endsAt: new Date('2019-12-01T00:00:00.000Z'),
+                conditions: [
+                    {
+                        code: promoCondition.code,
+                        arguments: [{ name: 'arg', value: '500' }],
+                    },
+                ],
+                actions: [
+                    {
+                        code: promoAction.code,
+                        arguments: [
+                            {
+                                name: 'facetValueIds',
+                                value: '["T_1"]',
+                            },
+                        ],
+                    },
+                ],
             },
-        );
-        promotion = result.createPromotion;
+        });
+        promotionGuard.assertSuccess(createPromotion);
+
+        promotion = createPromotion;
         expect(pick(promotion, snapshotProps)).toMatchSnapshot();
     });
 
-    it(
-        'createPromotion throws with empty conditions and no couponCode',
-        assertThrowsWithMessage(async () => {
-            await adminClient.query<CreatePromotion.Mutation, CreatePromotion.Variables>(CREATE_PROMOTION, {
-                input: {
-                    name: 'bad promotion',
-                    enabled: true,
-                    conditions: [],
-                    actions: [
-                        {
-                            code: promoAction.code,
-                            arguments: [
-                                {
-                                    name: 'facetValueIds',
-                                    value: '["T_1"]',
-                                },
-                            ],
-                        },
-                    ],
-                },
-            });
-        }, 'A Promotion must have either at least one condition or a coupon code set'),
-    );
+    it('createPromotion return error result with empty conditions and no couponCode', async () => {
+        const { createPromotion } = await adminClient.query<
+            CreatePromotion.Mutation,
+            CreatePromotion.Variables
+        >(CREATE_PROMOTION, {
+            input: {
+                name: 'bad promotion',
+                enabled: true,
+                conditions: [],
+                actions: [
+                    {
+                        code: promoAction.code,
+                        arguments: [
+                            {
+                                name: 'facetValueIds',
+                                value: '["T_1"]',
+                            },
+                        ],
+                    },
+                ],
+            },
+        });
+        promotionGuard.assertErrorResult(createPromotion);
+
+        expect(createPromotion.message).toBe(
+            'A Promotion must have either at least one condition or a coupon code set',
+        );
+        expect(createPromotion.code).toBe(ErrorCode.MISSING_CONDITIONS_ERROR);
+    });
 
     it('updatePromotion', async () => {
-        const result = await adminClient.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(
-            UPDATE_PROMOTION,
-            {
-                input: {
-                    id: promotion.id,
-                    couponCode: 'TEST1235',
-                    startsAt: new Date('2019-05-30T22:00:00.000Z'),
-                    endsAt: new Date('2019-06-01T22:00:00.000Z'),
-                    conditions: [
-                        {
-                            code: promoCondition.code,
-                            arguments: [{ name: 'arg', value: '90' }],
-                        },
-                        {
-                            code: promoCondition2.code,
-                            arguments: [{ name: 'arg', value: '10' }],
-                        },
-                    ],
-                },
+        const { updatePromotion } = await adminClient.query<
+            UpdatePromotion.Mutation,
+            UpdatePromotion.Variables
+        >(UPDATE_PROMOTION, {
+            input: {
+                id: promotion.id,
+                couponCode: 'TEST1235',
+                startsAt: new Date('2019-05-30T22:00:00.000Z'),
+                endsAt: new Date('2019-06-01T22:00:00.000Z'),
+                conditions: [
+                    {
+                        code: promoCondition.code,
+                        arguments: [{ name: 'arg', value: '90' }],
+                    },
+                    {
+                        code: promoCondition2.code,
+                        arguments: [{ name: 'arg', value: '10' }],
+                    },
+                ],
             },
-        );
-        expect(pick(result.updatePromotion, snapshotProps)).toMatchSnapshot();
+        });
+        promotionGuard.assertSuccess(updatePromotion);
+
+        expect(pick(updatePromotion, snapshotProps)).toMatchSnapshot();
     });
 
-    it(
-        'updatePromotion throws with empty conditions and no couponCode',
-        assertThrowsWithMessage(async () => {
-            await adminClient.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(UPDATE_PROMOTION, {
-                input: {
-                    id: promotion.id,
-                    couponCode: '',
-                    conditions: [],
-                },
-            });
-        }, 'A Promotion must have either at least one condition or a coupon code set'),
-    );
+    it('updatePromotion return error result with empty conditions and no couponCode', async () => {
+        const { updatePromotion } = await adminClient.query<
+            UpdatePromotion.Mutation,
+            UpdatePromotion.Variables
+        >(UPDATE_PROMOTION, {
+            input: {
+                id: promotion.id,
+                couponCode: '',
+                conditions: [],
+            },
+        });
+        promotionGuard.assertErrorResult(updatePromotion);
+
+        expect(updatePromotion.message).toBe(
+            'A Promotion must have either at least one condition or a coupon code set',
+        );
+        expect(updatePromotion.code).toBe(ErrorCode.MISSING_CONDITIONS_ERROR);
+    });
 
     it('promotion', async () => {
         const result = await adminClient.query<GetPromotion.Query, GetPromotion.Variables>(GET_PROMOTION, {
@@ -291,6 +313,10 @@ export const UPDATE_PROMOTION = gql`
     mutation UpdatePromotion($input: UpdatePromotionInput!) {
         updatePromotion(input: $input) {
             ...Promotion
+            ... on ErrorResult {
+                code
+                message
+            }
         }
     }
     ${PROMOTION_FRAGMENT}

+ 3 - 1
packages/core/e2e/session-management.e2e-spec.ts

@@ -104,7 +104,9 @@ describe('Session caching', () => {
         await adminClient.query(
             gql`
                 mutation Logout {
-                    logout
+                    logout {
+                        success
+                    }
                 }
             `,
         );

+ 9 - 19
packages/core/e2e/shop-auth.e2e-spec.ts

@@ -245,12 +245,8 @@ describe('Shop auth & accounts', () => {
         });
 
         it('login fails before verification', async () => {
-            try {
-                await shopClient.asUserWithCredentials(emailAddress, '');
-                fail('should have thrown');
-            } catch (err) {
-                expect(getErrorCode(err)).toBe('UNAUTHORIZED');
-            }
+            const result = await shopClient.asUserWithCredentials(emailAddress, '');
+            expect(result.code).toBe(ErrorCode.INVALID_CREDENTIALS_ERROR);
         });
 
         it('verification fails with wrong token', async () => {
@@ -508,7 +504,7 @@ describe('Shop auth & accounts', () => {
             expect(resetPassword.identifier).toBe(customer.emailAddress);
 
             const loginResult = await shopClient.asUserWithCredentials(customer.emailAddress, 'newPassword');
-            expect(loginResult.user.identifier).toBe(customer.emailAddress);
+            expect(loginResult.identifier).toBe(customer.emailAddress);
         });
 
         it('customer history for password reset', async () => {
@@ -624,12 +620,9 @@ describe('Shop auth & accounts', () => {
         });
 
         it('cannot login with new email address before verification', async () => {
-            try {
-                await shopClient.asUserWithCredentials(NEW_EMAIL_ADDRESS, PASSWORD);
-                fail('should have thrown');
-            } catch (err) {
-                expect(getErrorCode(err)).toBe('UNAUTHORIZED');
-            }
+            const result = await shopClient.asUserWithCredentials(NEW_EMAIL_ADDRESS, PASSWORD);
+
+            expect(result.code).toBe(ErrorCode.INVALID_CREDENTIALS_ERROR);
         });
 
         it('return error result for bad token', async () => {
@@ -664,12 +657,9 @@ describe('Shop auth & accounts', () => {
         });
 
         it('cannot login with old email address after verification', async () => {
-            try {
-                await shopClient.asUserWithCredentials(customer.emailAddress, PASSWORD);
-                fail('should have thrown');
-            } catch (err) {
-                expect(getErrorCode(err)).toBe('UNAUTHORIZED');
-            }
+            const result = await shopClient.asUserWithCredentials(customer.emailAddress, PASSWORD);
+
+            expect(result.code).toBe(ErrorCode.INVALID_CREDENTIALS_ERROR);
         });
 
         it('customer history for email update', async () => {

+ 1 - 1
packages/core/e2e/shop-customer.e2e-spec.ts

@@ -316,7 +316,7 @@ describe('Shop customers', () => {
 
             // Log out and log in with new password
             const loginResult = await shopClient.asUserWithCredentials(customer.emailAddress, 'test2');
-            expect(loginResult.user.identifier).toBe(customer.emailAddress);
+            expect(loginResult.identifier).toBe(customer.emailAddress);
         });
 
         it('customer history for CUSTOMER_PASSWORD_UPDATED', async () => {

+ 1 - 1
packages/core/src/api/middleware/translate-error-result-interceptor.ts

@@ -34,7 +34,7 @@ export class TranslateErrorResultInterceptor implements NestInterceptor {
 
     private translateResult(req: any, result: unknown) {
         if (result instanceof AdminErrorResult || result instanceof ShopErrorResult) {
-            this.i18nService.translateErrorResult(req, result);
+            this.i18nService.translateErrorResult(req, result as any);
         }
     }
 }

+ 19 - 8
packages/core/src/api/resolvers/admin/auth.resolver.ts

@@ -1,12 +1,15 @@
 import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
-    LoginResult,
+    AuthenticationResult,
     MutationAuthenticateArgs,
     MutationLoginArgs,
+    NativeAuthenticationResult,
     Permission,
+    Success,
 } from '@vendure/common/lib/generated-types';
 import { Request, Response } from 'express';
 
+import { NativeAuthStrategyError } from '../../../common/error/generated-graphql-admin-errors';
 import { ConfigService } from '../../../config/config.service';
 import { AdministratorService } from '../../../service/services/administrator.service';
 import { AuthService } from '../../../service/services/auth.service';
@@ -33,25 +36,29 @@ export class AuthResolver extends BaseAuthResolver {
     @Transaction()
     @Mutation()
     @Allow(Permission.Public)
-    login(
+    async login(
         @Args() args: MutationLoginArgs,
         @Ctx() ctx: RequestContext,
         @Context('req') req: Request,
         @Context('res') res: Response,
-    ): Promise<LoginResult> {
-        return super.login(args, ctx, req, res);
+    ): Promise<NativeAuthenticationResult> {
+        const nativeAuthStrategyError = this.requireNativeAuthStrategy();
+        if (nativeAuthStrategyError) {
+            return nativeAuthStrategyError;
+        }
+        return (await super.login(args, ctx, req, res)) as AuthenticationResult;
     }
 
     @Transaction()
     @Mutation()
     @Allow(Permission.Public)
-    authenticate(
+    async authenticate(
         @Args() args: MutationAuthenticateArgs,
         @Ctx() ctx: RequestContext,
         @Context('req') req: Request,
         @Context('res') res: Response,
-    ): Promise<LoginResult> {
-        return this.authenticateAndCreateSession(ctx, args, req, res);
+    ): Promise<AuthenticationResult> {
+        return (await this.authenticateAndCreateSession(ctx, args, req, res)) as AuthenticationResult;
     }
 
     @Transaction()
@@ -61,7 +68,7 @@ export class AuthResolver extends BaseAuthResolver {
         @Ctx() ctx: RequestContext,
         @Context('req') req: Request,
         @Context('res') res: Response,
-    ): Promise<boolean> {
+    ): Promise<Success> {
         return super.logout(ctx, req, res);
     }
 
@@ -70,4 +77,8 @@ export class AuthResolver extends BaseAuthResolver {
     me(@Ctx() ctx: RequestContext) {
         return super.me(ctx, 'admin');
     }
+
+    protected requireNativeAuthStrategy() {
+        return super.requireNativeAuthStrategy() as NativeAuthStrategyError | undefined;
+    }
 }

+ 17 - 7
packages/core/src/api/resolvers/admin/channel.resolver.ts

@@ -1,13 +1,16 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
+    CreateChannelResult,
     DeletionResponse,
     MutationCreateChannelArgs,
     MutationDeleteChannelArgs,
     MutationUpdateChannelArgs,
     Permission,
     QueryChannelArgs,
+    UpdateChannelResult,
 } from '@vendure/common/lib/generated-types';
 
+import { ErrorResultUnion, isGraphQlErrorResult } from '../../../common/error/error-result';
 import { Channel } from '../../../entity/channel/channel.entity';
 import { ChannelService } from '../../../service/services/channel.service';
 import { RoleService } from '../../../service/services/role.service';
@@ -44,13 +47,16 @@ export class ChannelResolver {
     async createChannel(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationCreateChannelArgs,
-    ): Promise<Channel> {
-        const channel = await this.channelService.create(ctx, args.input);
+    ): Promise<ErrorResultUnion<CreateChannelResult, Channel>> {
+        const result = await this.channelService.create(ctx, args.input);
+        if (isGraphQlErrorResult(result)) {
+            return result;
+        }
         const superAdminRole = await this.roleService.getSuperAdminRole();
         const customerRole = await this.roleService.getCustomerRole();
-        await this.roleService.assignRoleToChannel(ctx, superAdminRole.id, channel.id);
-        await this.roleService.assignRoleToChannel(ctx, customerRole.id, channel.id);
-        return channel;
+        await this.roleService.assignRoleToChannel(ctx, superAdminRole.id, result.id);
+        await this.roleService.assignRoleToChannel(ctx, customerRole.id, result.id);
+        return result;
     }
 
     @Transaction()
@@ -59,8 +65,12 @@ export class ChannelResolver {
     async updateChannel(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationUpdateChannelArgs,
-    ): Promise<Channel> {
-        return this.channelService.update(ctx, args.input);
+    ): Promise<ErrorResultUnion<UpdateChannelResult, Channel>> {
+        const result = await this.channelService.update(ctx, args.input);
+        if (isGraphQlErrorResult(result)) {
+            return result;
+        }
+        return result;
     }
 
     @Transaction()

+ 5 - 3
packages/core/src/api/resolvers/admin/customer.resolver.ts

@@ -1,8 +1,8 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
+    CreateCustomerResult,
     DeletionResponse,
     MutationAddNoteToCustomerArgs,
-    MutationAddNoteToOrderArgs,
     MutationCreateCustomerAddressArgs,
     MutationCreateCustomerArgs,
     MutationDeleteCustomerAddressArgs,
@@ -15,9 +15,11 @@ import {
     QueryCustomerArgs,
     QueryCustomersArgs,
     Success,
+    UpdateCustomerResult,
 } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
+import { ErrorResultUnion } from '../../../common/error/error-result';
 import { Address } from '../../../entity/address/address.entity';
 import { Customer } from '../../../entity/customer/customer.entity';
 import { CustomerService } from '../../../service/services/customer.service';
@@ -55,7 +57,7 @@ export class CustomerResolver {
     async createCustomer(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationCreateCustomerArgs,
-    ): Promise<Customer> {
+    ): Promise<ErrorResultUnion<CreateCustomerResult, Customer>> {
         const { input, password } = args;
         return this.customerService.create(ctx, input, password || undefined);
     }
@@ -66,7 +68,7 @@ export class CustomerResolver {
     async updateCustomer(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationUpdateCustomerArgs,
-    ): Promise<Customer> {
+    ): Promise<ErrorResultUnion<UpdateCustomerResult, Customer>> {
         const { input } = args;
         return this.customerService.update(ctx, input);
     }

+ 12 - 5
packages/core/src/api/resolvers/admin/global-settings.resolver.ts

@@ -3,11 +3,15 @@ import {
     MutationUpdateGlobalSettingsArgs,
     OrderProcessState,
     Permission,
+    UpdateGlobalSettingsResult,
 } from '@vendure/common/lib/generated-types';
 
+import { ErrorResultUnion } from '../../../common/error/error-result';
 import { UserInputError } from '../../../common/error/errors';
+import { ChannelDefaultLanguageError } from '../../../common/error/generated-graphql-admin-errors';
 import { ConfigService } from '../../../config/config.service';
 import { CustomFields } from '../../../config/custom-field/custom-field-types';
+import { GlobalSettings } from '../../../entity/global-settings/global-settings.entity';
 import { ChannelService } from '../../../service/services/channel.service';
 import { GlobalSettingsService } from '../../../service/services/global-settings.service';
 import { OrderService } from '../../../service/services/order.service';
@@ -57,7 +61,10 @@ export class GlobalSettingsResolver {
     @Transaction()
     @Mutation()
     @Allow(Permission.UpdateSettings)
-    async updateGlobalSettings(@Ctx() ctx: RequestContext, @Args() args: MutationUpdateGlobalSettingsArgs) {
+    async updateGlobalSettings(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationUpdateGlobalSettingsArgs,
+    ): Promise<ErrorResultUnion<UpdateGlobalSettingsResult, GlobalSettings>> {
         // This validation is performed here in the resolver rather than at the service
         // layer to avoid a circular dependency [ChannelService <> GlobalSettingsService]
         const { availableLanguages } = args.input;
@@ -67,10 +74,10 @@ export class GlobalSettingsResolver {
                 c => !availableLanguages.includes(c.defaultLanguageCode),
             );
             if (unavailableDefaults.length) {
-                throw new UserInputError('error.cannot-set-default-language-as-unavailable', {
-                    language: unavailableDefaults.map(c => c.defaultLanguageCode).join(', '),
-                    channelCode: unavailableDefaults.map(c => c.code).join(', '),
-                });
+                return new ChannelDefaultLanguageError(
+                    unavailableDefaults.map(c => c.defaultLanguageCode).join(', '),
+                    unavailableDefaults.map(c => c.code).join(', '),
+                );
             }
         }
         return this.globalSettingsService.updateSettings(ctx, args.input);

+ 25 - 5
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -1,9 +1,11 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
+    AddFulfillmentToOrderResult,
+    CancelOrderResult,
+    MutationAddFulfillmentToOrderArgs,
     MutationAddNoteToOrderArgs,
     MutationCancelOrderArgs,
     MutationDeleteOrderNoteArgs,
-    MutationFulfillOrderArgs,
     MutationRefundOrderArgs,
     MutationSetOrderCustomFieldsArgs,
     MutationSettlePaymentArgs,
@@ -14,10 +16,16 @@ import {
     Permission,
     QueryOrderArgs,
     QueryOrdersArgs,
+    RefundOrderResult,
+    SettlePaymentResult,
 } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
+import { ErrorResultUnion } from '../../../common/error/error-result';
+import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
 import { Order } from '../../../entity/order/order.entity';
+import { Payment } from '../../../entity/payment/payment.entity';
+import { Refund } from '../../../entity/refund/refund.entity';
 import { FulfillmentState } from '../../../service/helpers/fulfillment-state-machine/fulfillment-state';
 import { OrderState } from '../../../service/helpers/order-state-machine/order-state';
 import { OrderService } from '../../../service/services/order.service';
@@ -46,28 +54,40 @@ export class OrderResolver {
     @Transaction()
     @Mutation()
     @Allow(Permission.UpdateOrder)
-    async settlePayment(@Ctx() ctx: RequestContext, @Args() args: MutationSettlePaymentArgs) {
+    async settlePayment(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationSettlePaymentArgs,
+    ): Promise<ErrorResultUnion<SettlePaymentResult, Payment>> {
         return this.orderService.settlePayment(ctx, args.id);
     }
 
     @Transaction()
     @Mutation()
     @Allow(Permission.UpdateOrder)
-    async fulfillOrder(@Ctx() ctx: RequestContext, @Args() args: MutationFulfillOrderArgs) {
+    async addFulfillmentToOrder(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationAddFulfillmentToOrderArgs,
+    ): Promise<ErrorResultUnion<AddFulfillmentToOrderResult, Fulfillment>> {
         return this.orderService.createFulfillment(ctx, args.input);
     }
 
     @Transaction()
     @Mutation()
     @Allow(Permission.UpdateOrder)
-    async cancelOrder(@Ctx() ctx: RequestContext, @Args() args: MutationCancelOrderArgs) {
+    async cancelOrder(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationCancelOrderArgs,
+    ): Promise<ErrorResultUnion<CancelOrderResult, Order>> {
         return this.orderService.cancelOrder(ctx, args.input);
     }
 
     @Transaction()
     @Mutation()
     @Allow(Permission.UpdateOrder)
-    async refundOrder(@Ctx() ctx: RequestContext, @Args() args: MutationRefundOrderArgs) {
+    async refundOrder(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationRefundOrderArgs,
+    ): Promise<ErrorResultUnion<RefundOrderResult, Refund>> {
         return this.orderService.refundOrder(ctx, args.input);
     }
 

+ 3 - 1
packages/core/src/api/resolvers/admin/product.resolver.ts

@@ -15,9 +15,11 @@ import {
     QueryProductArgs,
     QueryProductsArgs,
     QueryProductVariantArgs,
+    RemoveOptionGroupFromProductResult,
 } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
+import { ErrorResultUnion } from '../../../common/error/error-result';
 import { UserInputError } from '../../../common/error/errors';
 import { Translated } from '../../../common/types/locale-types';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
@@ -124,7 +126,7 @@ export class ProductResolver {
     async removeOptionGroupFromProduct(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationRemoveOptionGroupFromProductArgs,
-    ): Promise<Translated<Product>> {
+    ): Promise<ErrorResultUnion<RemoveOptionGroupFromProductResult, Translated<Product>>> {
         const { productId, optionGroupId } = args;
         return this.productService.removeOptionGroupFromProduct(ctx, productId, optionGroupId);
     }

+ 14 - 7
packages/core/src/api/resolvers/admin/promotion.resolver.ts

@@ -1,5 +1,6 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
+    CreatePromotionResult,
     DeletionResponse,
     MutationCreatePromotionArgs,
     MutationDeletePromotionArgs,
@@ -7,9 +8,11 @@ import {
     Permission,
     QueryPromotionArgs,
     QueryPromotionsArgs,
+    UpdatePromotionResult,
 } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
+import { ErrorResultUnion } from '../../../common/error/error-result';
 import { PromotionItemAction, PromotionOrderAction } from '../../../config/promotion/promotion-action';
 import { PromotionCondition } from '../../../config/promotion/promotion-condition';
 import { Promotion } from '../../../entity/promotion/promotion.entity';
@@ -63,7 +66,7 @@ export class PromotionResolver {
     createPromotion(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationCreatePromotionArgs,
-    ): Promise<Promotion> {
+    ): Promise<ErrorResultUnion<CreatePromotionResult, Promotion>> {
         this.configurableOperationCodec.decodeConfigurableOperationIds(
             PromotionOrderAction,
             args.input.actions,
@@ -81,7 +84,7 @@ export class PromotionResolver {
     updatePromotion(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationUpdatePromotionArgs,
-    ): Promise<Promotion> {
+    ): Promise<ErrorResultUnion<UpdatePromotionResult, Promotion>> {
         this.configurableOperationCodec.decodeConfigurableOperationIds(
             PromotionOrderAction,
             args.input.actions || [],
@@ -110,17 +113,21 @@ export class PromotionResolver {
     /**
      * Encodes any entity IDs used in the filter arguments.
      */
-    private encodeConditionsAndActions = <T extends Promotion | undefined>(collection: T): T => {
-        if (collection) {
+    private encodeConditionsAndActions = <
+        T extends ErrorResultUnion<CreatePromotionResult, Promotion> | undefined
+    >(
+        maybePromotion: T,
+    ): T => {
+        if (maybePromotion instanceof Promotion) {
             this.configurableOperationCodec.encodeConfigurableOperationIds(
                 PromotionOrderAction,
-                collection.actions,
+                maybePromotion.actions,
             );
             this.configurableOperationCodec.encodeConfigurableOperationIds(
                 PromotionCondition,
-                collection.conditions,
+                maybePromotion.conditions,
             );
         }
-        return collection;
+        return maybePromotion;
     };
 }

+ 43 - 12
packages/core/src/api/resolvers/base/base-auth.resolver.ts

@@ -1,16 +1,24 @@
+import { AuthenticationResult as ShopAuthenticationResult } from '@vendure/common/lib/generated-shop-types';
 import {
+    AuthenticationResult as AdminAuthenticationResult,
     CurrentUser,
     CurrentUserChannel,
-    LoginResult,
     MutationAuthenticateArgs,
     MutationLoginArgs,
+    Success,
 } from '@vendure/common/lib/generated-types';
 import { Request, Response } from 'express';
 
+import { isGraphQlErrorResult } from '../../../common/error/error-result';
 import { ForbiddenError, UnauthorizedError } from '../../../common/error/errors';
-import { InvalidCredentialsError } from '../../../common/error/generated-graphql-shop-errors';
+import { NativeAuthStrategyError as AdminNativeAuthStrategyError } from '../../../common/error/generated-graphql-admin-errors';
+import {
+    InvalidCredentialsError,
+    NativeAuthStrategyError as ShopNativeAuthStrategyError,
+} from '../../../common/error/generated-graphql-shop-errors';
 import { NATIVE_AUTH_STRATEGY_NAME } from '../../../config/auth/native-authentication-strategy';
 import { ConfigService } from '../../../config/config.service';
+import { Logger } from '../../../config/logger/vendure-logger';
 import { User } from '../../../entity/user/user.entity';
 import { getUserChannelsPermissions } from '../../../service/helpers/utils/get-user-channels-permissions';
 import { AdministratorService } from '../../../service/services/administrator.service';
@@ -22,12 +30,18 @@ import { RequestContext } from '../../common/request-context';
 import { setSessionToken } from '../../common/set-session-token';
 
 export class BaseAuthResolver {
+    protected readonly nativeAuthStrategyIsConfigured: boolean;
+
     constructor(
         protected authService: AuthService,
         protected userService: UserService,
         protected administratorService: AdministratorService,
         protected configService: ConfigService,
-    ) {}
+    ) {
+        this.nativeAuthStrategyIsConfigured = !!this.configService.authOptions.shopAuthenticationStrategy.find(
+            strategy => strategy.name === NATIVE_AUTH_STRATEGY_NAME,
+        );
+    }
 
     /**
      * Attempts a login given the username and password of a user. If successful, returns
@@ -38,7 +52,7 @@ export class BaseAuthResolver {
         ctx: RequestContext,
         req: Request,
         res: Response,
-    ): Promise<LoginResult> {
+    ): Promise<AdminAuthenticationResult | ShopAuthenticationResult> {
         return await this.authenticateAndCreateSession(
             ctx,
             {
@@ -49,10 +63,10 @@ export class BaseAuthResolver {
         );
     }
 
-    async logout(ctx: RequestContext, req: Request, res: Response): Promise<boolean> {
+    async logout(ctx: RequestContext, req: Request, res: Response): Promise<Success> {
         const token = extractSessionToken(req, this.configService.authOptions.tokenMethod);
         if (!token) {
-            return false;
+            return { success: false };
         }
         await this.authService.destroyAuthenticatedSession(ctx, token);
         setSessionToken({
@@ -62,7 +76,7 @@ export class BaseAuthResolver {
             rememberMe: false,
             sessionToken: '',
         });
-        return true;
+        return { success: true };
     }
 
     /**
@@ -91,14 +105,17 @@ export class BaseAuthResolver {
         args: MutationAuthenticateArgs,
         req: Request,
         res: Response,
-    ): Promise<LoginResult> {
+    ): Promise<AdminAuthenticationResult | ShopAuthenticationResult> {
         const [method, data] = Object.entries(args.input)[0];
         const { apiType } = ctx;
         const session = await this.authService.authenticate(ctx, apiType, method, data);
+        if (isGraphQlErrorResult(session)) {
+            return session;
+        }
         if (apiType && apiType === 'admin') {
             const administrator = await this.administratorService.findOneByUserId(ctx, session.user.id);
             if (!administrator) {
-                throw new UnauthorizedError();
+                return new InvalidCredentialsError();
             }
         }
         setSessionToken({
@@ -108,9 +125,7 @@ export class BaseAuthResolver {
             rememberMe: args.rememberMe || false,
             sessionToken: session.token,
         });
-        return {
-            user: this.publiclyAccessibleUser(session.user),
-        };
+        return this.publiclyAccessibleUser(session.user);
     }
 
     /**
@@ -138,4 +153,20 @@ export class BaseAuthResolver {
             channels: getUserChannelsPermissions(user) as CurrentUserChannel[],
         };
     }
+
+    protected requireNativeAuthStrategy():
+        | AdminNativeAuthStrategyError
+        | ShopNativeAuthStrategyError
+        | undefined {
+        if (!this.nativeAuthStrategyIsConfigured) {
+            const authStrategyNames = this.configService.authOptions.shopAuthenticationStrategy
+                .map(s => s.name)
+                .join(', ');
+            const errorMessage =
+                'This GraphQL operation requires that the NativeAuthenticationStrategy be configured for the Shop API.\n' +
+                `Currently the following AuthenticationStrategies are enabled: ${authStrategyNames}`;
+            Logger.error(errorMessage);
+            return new AdminNativeAuthStrategyError();
+        }
+    }
 }

+ 31 - 35
packages/core/src/api/resolvers/shop/shop-auth.resolver.ts

@@ -1,7 +1,9 @@
 import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
+    AuthenticationResult,
     ErrorCode,
-    LoginResult,
+    InvalidCredentialsError,
+    MissingPasswordError,
     MutationAuthenticateArgs,
     MutationLoginArgs,
     MutationRefreshCustomerVerificationArgs,
@@ -12,12 +14,14 @@ import {
     MutationUpdateCustomerEmailAddressArgs,
     MutationUpdateCustomerPasswordArgs,
     MutationVerifyCustomerAccountArgs,
+    NativeAuthenticationResult,
     Permission,
     RefreshCustomerVerificationResult,
     RegisterCustomerAccountResult,
     RequestPasswordResetResult,
     RequestUpdateCustomerEmailAddressResult,
     ResetPasswordResult,
+    Success,
     UpdateCustomerEmailAddressResult,
     UpdateCustomerPasswordResult,
     VerifyCustomerAccountResult,
@@ -30,7 +34,6 @@ import { ForbiddenError } from '../../../common/error/errors';
 import { NativeAuthStrategyError } from '../../../common/error/generated-graphql-shop-errors';
 import { NATIVE_AUTH_STRATEGY_NAME } from '../../../config/auth/native-authentication-strategy';
 import { ConfigService } from '../../../config/config.service';
-import { Logger } from '../../../config/logger/vendure-logger';
 import { AdministratorService } from '../../../service/services/administrator.service';
 import { AuthService } from '../../../service/services/auth.service';
 import { CustomerService } from '../../../service/services/customer.service';
@@ -45,8 +48,6 @@ import { BaseAuthResolver } from '../base/base-auth.resolver';
 
 @Resolver()
 export class ShopAuthResolver extends BaseAuthResolver {
-    private readonly nativeAuthStrategyIsConfigured: boolean;
-
     constructor(
         authService: AuthService,
         userService: UserService,
@@ -56,44 +57,44 @@ export class ShopAuthResolver extends BaseAuthResolver {
         protected historyService: HistoryService,
     ) {
         super(authService, userService, administratorService, configService);
-        this.nativeAuthStrategyIsConfigured = !!this.configService.authOptions.shopAuthenticationStrategy.find(
-            strategy => strategy.name === NATIVE_AUTH_STRATEGY_NAME,
-        );
     }
 
     @Transaction()
     @Mutation()
     @Allow(Permission.Public)
-    login(
+    async login(
         @Args() args: MutationLoginArgs,
         @Ctx() ctx: RequestContext,
         @Context('req') req: Request,
         @Context('res') res: Response,
-    ): Promise<LoginResult> {
-        this.requireNativeAuthStrategy();
-        return super.login(args, ctx, req, res);
+    ): Promise<NativeAuthenticationResult> {
+        const nativeAuthStrategyError = this.requireNativeAuthStrategy();
+        if (nativeAuthStrategyError) {
+            return nativeAuthStrategyError;
+        }
+        return (await super.login(args, ctx, req, res)) as AuthenticationResult;
     }
 
     @Transaction()
     @Mutation()
     @Allow(Permission.Public)
-    authenticate(
+    async authenticate(
         @Args() args: MutationAuthenticateArgs,
         @Ctx() ctx: RequestContext,
         @Context('req') req: Request,
         @Context('res') res: Response,
-    ): Promise<LoginResult> {
-        return this.authenticateAndCreateSession(ctx, args, req, res);
+    ): Promise<AuthenticationResult> {
+        return (await this.authenticateAndCreateSession(ctx, args, req, res)) as AuthenticationResult;
     }
 
     @Transaction()
     @Mutation()
     @Allow(Permission.Public)
-    logout(
+    async logout(
         @Ctx() ctx: RequestContext,
         @Context('req') req: Request,
         @Context('res') res: Response,
-    ): Promise<boolean> {
+    ): Promise<Success> {
         return super.logout(ctx, req, res);
     }
 
@@ -121,7 +122,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
                 // otherwise account enumeration attacks become possible.
                 return { success: true };
             }
-            return result;
+            return result as MissingPasswordError;
         }
         return { success: true };
     }
@@ -210,17 +211,17 @@ export class ShopAuthResolver extends BaseAuthResolver {
             return nativeAuthStrategyError;
         }
         const { token, password } = args;
-        const result = await this.customerService.resetPassword(ctx, token, password);
-        if (isGraphQlErrorResult(result)) {
-            return result;
+        const resetResult = await this.customerService.resetPassword(ctx, token, password);
+        if (isGraphQlErrorResult(resetResult)) {
+            return resetResult;
         }
 
-        const { user } = await super.authenticateAndCreateSession(
+        const authResult = await super.authenticateAndCreateSession(
             ctx,
             {
                 input: {
                     [NATIVE_AUTH_STRATEGY_NAME]: {
-                        username: result.identifier,
+                        username: resetResult.identifier,
                         password: args.password,
                     },
                 },
@@ -228,7 +229,11 @@ export class ShopAuthResolver extends BaseAuthResolver {
             req,
             res,
         );
-        return user;
+        if (isGraphQlErrorResult(authResult)) {
+            // This should never occur in theory
+            throw authResult;
+        }
+        return authResult;
     }
 
     @Transaction()
@@ -276,7 +281,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         }
         const verify = await this.authService.verifyUserPassword(ctx, ctx.activeUserId, args.password);
         if (isGraphQlErrorResult(verify)) {
-            return verify;
+            return verify as InvalidCredentialsError;
         }
         const result = await this.customerService.requestUpdateEmailAddress(
             ctx,
@@ -309,16 +314,7 @@ export class ShopAuthResolver extends BaseAuthResolver {
         return { success: result };
     }
 
-    private requireNativeAuthStrategy(): NativeAuthStrategyError | undefined {
-        if (!this.nativeAuthStrategyIsConfigured) {
-            const authStrategyNames = this.configService.authOptions.shopAuthenticationStrategy
-                .map(s => s.name)
-                .join(', ');
-            const errorMessage =
-                'This GraphQL operation requires that the NativeAuthenticationStrategy be configured for the Shop API.\n' +
-                `Currently the following AuthenticationStrategies are enabled: ${authStrategyNames}`;
-            Logger.error(errorMessage);
-            return new NativeAuthStrategyError();
-        }
+    protected requireNativeAuthStrategy() {
+        return super.requireNativeAuthStrategy() as NativeAuthStrategyError | undefined;
     }
 }

+ 5 - 2
packages/core/src/api/resolvers/shop/shop-customer.resolver.ts

@@ -1,9 +1,12 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
-import { MutationDeleteCustomerAddressArgs, Success } from '@vendure/common/lib/generated-shop-types';
+import {
+    MutationDeleteCustomerAddressArgs,
+    MutationUpdateCustomerArgs,
+    Success,
+} from '@vendure/common/lib/generated-shop-types';
 import {
     MutationCreateCustomerAddressArgs,
     MutationUpdateCustomerAddressArgs,
-    MutationUpdateCustomerArgs,
     Permission,
 } from '@vendure/common/lib/generated-types';
 

+ 6 - 3
packages/core/src/api/schema/admin-api/auth.api.graphql

@@ -4,11 +4,14 @@ type Query {
 
 type Mutation {
     "Authenticates the user using the native authentication strategy. This mutation is an alias for `authenticate({ native: { ... }})`"
-    login(username: String!, password: String!, rememberMe: Boolean): LoginResult!
+    login(username: String!, password: String!, rememberMe: Boolean): NativeAuthenticationResult!
     "Authenticates the user using a named authentication strategy"
-    authenticate(input: AuthenticationInput!, rememberMe: Boolean): LoginResult!
-    logout: Boolean!
+    authenticate(input: AuthenticationInput!, rememberMe: Boolean): AuthenticationResult!
+    logout: Success!
 }
 
 # Populated at run-time
 input AuthenticationInput
+
+union NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError
+union AuthenticationResult = CurrentUser | InvalidCredentialsError

+ 12 - 2
packages/core/src/api/schema/admin-api/channel.api.graphql

@@ -6,10 +6,10 @@ type Query {
 
 type Mutation {
     "Create a new Channel"
-    createChannel(input: CreateChannelInput!): Channel!
+    createChannel(input: CreateChannelInput!): CreateChannelResult!
 
     "Update an existing Channel"
-    updateChannel(input: UpdateChannelInput!): Channel!
+    updateChannel(input: UpdateChannelInput!): UpdateChannelResult!
 
     "Delete a Channel"
     deleteChannel(id: ID!): DeletionResponse!
@@ -35,3 +35,13 @@ input UpdateChannelInput {
     defaultTaxZoneId: ID
     defaultShippingZoneId: ID
 }
+
+"Returned if attempting to set a Channel's defaultLanguageCode to a language which is not enabled in GlobalSettings"
+type LanguageNotAvailableError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+    languageCode: String!
+}
+
+union CreateChannelResult = Channel | LanguageNotAvailableError
+union UpdateChannelResult = Channel | LanguageNotAvailableError

+ 5 - 2
packages/core/src/api/schema/admin-api/customer.api.graphql

@@ -5,10 +5,10 @@ type Query {
 
 type Mutation {
     "Create a new Customer. If a password is provided, a new User will also be created an linked to the Customer."
-    createCustomer(input: CreateCustomerInput!, password: String): Customer!
+    createCustomer(input: CreateCustomerInput!, password: String): CreateCustomerResult!
 
     "Update an existing Customer"
-    updateCustomer(input: UpdateCustomerInput!): Customer!
+    updateCustomer(input: UpdateCustomerInput!): UpdateCustomerResult!
 
     "Delete a Customer"
     deleteCustomer(id: ID!): DeletionResponse!
@@ -56,3 +56,6 @@ input UpdateCustomerNoteInput {
     note: String!
 }
 
+union CreateCustomerResult = Customer | EmailAddressConflictError
+union UpdateCustomerResult = Customer | EmailAddressConflictError
+

+ 14 - 1
packages/core/src/api/schema/admin-api/global-settings.api.graphql

@@ -3,10 +3,23 @@ type Query {
 }
 
 type Mutation {
-    updateGlobalSettings(input: UpdateGlobalSettingsInput!): GlobalSettings!
+    updateGlobalSettings(input: UpdateGlobalSettingsInput!): UpdateGlobalSettingsResult!
 }
 
 input UpdateGlobalSettingsInput {
     availableLanguages: [LanguageCode!]
     trackInventory: Boolean
 }
+
+"""
+Returned when the default LanguageCode of a Channel is no longer found in the `availableLanguages`
+of the GlobalSettings
+"""
+type ChannelDefaultLanguageError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+    language: String!
+    channelCode: String!
+}
+
+union UpdateGlobalSettingsResult = GlobalSettings | ChannelDefaultLanguageError

+ 118 - 6
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -4,16 +4,16 @@ type Query {
 }
 
 type Mutation {
-    settlePayment(id: ID!): Payment!
-    fulfillOrder(input: FulfillOrderInput!): Fulfillment!
-    cancelOrder(input: CancelOrderInput!): Order!
-    refundOrder(input: RefundOrderInput!): Refund!
-    settleRefund(input: SettleRefundInput!): Refund!
+    settlePayment(id: ID!): SettlePaymentResult!
+    addFulfillmentToOrder(input: FulfillOrderInput!): AddFulfillmentToOrderResult!
+    cancelOrder(input: CancelOrderInput!): CancelOrderResult!
+    refundOrder(input: RefundOrderInput!): RefundOrderResult!
+    settleRefund(input: SettleRefundInput!): SettleRefundResult!
     addNoteToOrder(input: AddNoteToOrderInput!): Order!
     updateOrderNote(input: UpdateOrderNoteInput!): HistoryEntry!
     deleteOrderNote(id: ID!): DeletionResponse!
     transitionOrderToState(id: ID!, state: String!): TransitionOrderToStateResult
-    transitionFulfillmentToState(id: ID!, state: String!): Fulfillment!
+    transitionFulfillmentToState(id: ID!, state: String!): TransitionFulfillmentToStateResult!
     setOrderCustomFields(input: UpdateOrderInput!): Order
 }
 
@@ -73,4 +73,116 @@ input UpdateOrderNoteInput {
     isPublic: Boolean
 }
 
+"Returned if the Payment settlement fails"
+type SettlePaymentError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+    paymentErrorMessage: String!
+}
+
+"Returned if no OrderLines have been specified for the operation"
+type EmptyOrderLineSelectionError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+}
+
+"Returned if the specified items are already part of a Fulfillment"
+type ItemsAlreadyFulfilledError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+}
+
+"Returned if an operation has specified OrderLines from multiple Orders"
+type MultipleOrderError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+}
+
+"Returned if an attempting to cancel lines from an Order which is still active"
+type CancelActiveOrderError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+    orderState: String!
+}
+
+"Returned if an attempting to refund a Payment against OrderLines from a different Order"
+type PaymentOrderMismatchError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+}
+
+"Returned if an attempting to refund an Order which is not in the expected state"
+type RefundOrderStateError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+    orderState: String!
+}
+
+"Returned if an attempting to refund an Order but neither items nor shipping refund was specified"
+type NothingToRefundError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+}
+
+"Returned if an attempting to refund an OrderItem which has already been refunded"
+type AlreadyRefundedError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+    refundId: ID!
+}
+
+"Returned if the specified quantity of an OrderLine is greater than the number of items in that line"
+type QuantityTooGreatError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+}
+
+"Returned when there is an error in transitioning the Refund state"
+type RefundStateTransitionError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+    transitionError: String!
+    fromState: String!
+    toState: String!
+}
+
+"Returned when there is an error in transitioning the Payment state"
+type PaymentStateTransitionError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+    transitionError: String!
+    fromState: String!
+    toState: String!
+}
+
+"Returned when there is an error in transitioning the Fulfillment state"
+type FulfillmentStateTransitionError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+    transitionError: String!
+    fromState: String!
+    toState: String!
+}
+
 union TransitionOrderToStateResult = Order | OrderStateTransitionError
+union SettlePaymentResult = Payment | SettlePaymentError | PaymentStateTransitionError | OrderStateTransitionError
+union AddFulfillmentToOrderResult = Fulfillment | EmptyOrderLineSelectionError | ItemsAlreadyFulfilledError
+union CancelOrderResult =
+      Order
+    | EmptyOrderLineSelectionError
+    | QuantityTooGreatError
+    | MultipleOrderError
+    | CancelActiveOrderError
+    | OrderStateTransitionError
+union RefundOrderResult =
+      Refund
+    | QuantityTooGreatError
+    | NothingToRefundError
+    | OrderStateTransitionError
+    | MultipleOrderError
+    | PaymentOrderMismatchError
+    | RefundOrderStateError
+    | AlreadyRefundedError
+    | RefundStateTransitionError
+union SettleRefundResult = Refund | RefundStateTransitionError
+union TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError

+ 10 - 1
packages/core/src/api/schema/admin-api/product.api.graphql

@@ -20,7 +20,7 @@ type Mutation {
     addOptionGroupToProduct(productId: ID!, optionGroupId: ID!): Product!
 
     "Remove an OptionGroup from a Product"
-    removeOptionGroupFromProduct(productId: ID!, optionGroupId: ID!): Product!
+    removeOptionGroupFromProduct(productId: ID!, optionGroupId: ID!): RemoveOptionGroupFromProductResult!
 
     "Create a set of ProductVariants based on the OptionGroups assigned to the given Product"
     createProductVariants(input: [CreateProductVariantInput!]!): [ProductVariant]!
@@ -139,3 +139,12 @@ input RemoveProductsFromChannelInput {
     productIds: [ID!]!
     channelId: ID!
 }
+
+type ProductOptionInUseError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+    optionGroupCode: String!
+    productVariantCount: Int!
+}
+
+union RemoveOptionGroupFromProductResult = Product | ProductOptionInUseError

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

@@ -6,8 +6,8 @@ type Query {
 }
 
 type Mutation {
-    createPromotion(input: CreatePromotionInput!): Promotion!
-    updatePromotion(input: UpdatePromotionInput!): Promotion!
+    createPromotion(input: CreatePromotionInput!): CreatePromotionResult!
+    updatePromotion(input: UpdatePromotionInput!): UpdatePromotionResult!
     deletePromotion(id: ID!): DeletionResponse!
 }
 
@@ -36,3 +36,12 @@ input UpdatePromotionInput {
     conditions: [ConfigurableOperationInput!]
     actions: [ConfigurableOperationInput!]
 }
+
+"Returned if a PromotionCondition has neither a couponCode nor any conditions set"
+type MissingConditionsError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+}
+
+union CreatePromotionResult = Promotion | MissingConditionsError
+union UpdatePromotionResult = Promotion | MissingConditionsError

+ 18 - 0
packages/core/src/api/schema/common/common-types.graphql

@@ -191,6 +191,18 @@ type Success {
     success: Boolean!
 }
 
+"Retured when attempting an operation that relies on the NativeAuthStrategy, if that strategy is not configured."
+type NativeAuthStrategyError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+}
+
+"Returned if the user authentication credentials are not valid"
+type InvalidCredentialsError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+}
+
 "Returned if there is an error in transitioning the Order state"
 type OrderStateTransitionError implements ErrorResult {
     code: ErrorCode!
@@ -199,3 +211,9 @@ type OrderStateTransitionError implements ErrorResult {
     fromState: String!
     toState: String!
 }
+
+"Retured when attemting to create a Customer with an email address already registered to an existing User."
+type EmailAddressConflictError implements ErrorResult {
+    code: ErrorCode!
+    message: String!
+}

+ 5 - 21
packages/core/src/api/schema/shop-api/shop.api.graphql

@@ -69,11 +69,11 @@ type Mutation {
     "Set the Customer for the Order. Required only if the Customer is not currently logged in"
     setCustomerForOrder(input: CreateCustomerInput!): SetCustomerForOrderResult
     "Authenticates the user using the native authentication strategy. This mutation is an alias for `authenticate({ native: { ... }})`"
-    login(username: String!, password: String!, rememberMe: Boolean): LoginResult!
+    login(username: String!, password: String!, rememberMe: Boolean): NativeAuthenticationResult!
     "Authenticates the user using a named authentication strategy"
-    authenticate(input: AuthenticationInput!, rememberMe: Boolean): LoginResult!
+    authenticate(input: AuthenticationInput!, rememberMe: Boolean): AuthenticationResult!
     "End the current authenticated session"
-    logout: Boolean!
+    logout: Success!
     """
     Register a Customer account with the given credentials. There are three possible registration flows:
 
@@ -252,12 +252,6 @@ type AlreadyLoggedInError implements ErrorResult {
     message: String!
 }
 
-"Retured when attemting to create a Customer with an email address already registered to an existing User."
-type EmailAddressConflictError implements ErrorResult {
-    code: ErrorCode!
-    message: String!
-}
-
 "Retured when attemting to register or verify a customer account without a password, when one is required."
 type MissingPasswordError implements ErrorResult {
     code: ErrorCode!
@@ -270,12 +264,6 @@ type PasswordAlreadySetError implements ErrorResult {
     message: String!
 }
 
-"Retured when attempting an operation that relies on the NativeAuthStrategy, if that strategy is not configured."
-type NativeAuthStrategyError implements ErrorResult {
-    code: ErrorCode!
-    message: String!
-}
-
 """
 Retured if the verification token (used to verify a Customer's email address) is either
 invalid or does not match any expected tokens.
@@ -312,12 +300,6 @@ type IdentifierChangeTokenExpiredError implements ErrorResult {
     message: String!
 }
 
-"Returned if the user credentials are not valid"
-type InvalidCredentialsError implements ErrorResult {
-    code: ErrorCode!
-    message: String!
-}
-
 """
 Retured if the token used to reset a Customer's password is either
 invalid or does not match any expected tokens.
@@ -370,3 +352,5 @@ union UpdateCustomerEmailAddressResult =
     | NativeAuthStrategyError
 union RequestPasswordResetResult = Success | NativeAuthStrategyError
 union ResetPasswordResult = CurrentUser | PasswordResetTokenInvalidError | PasswordResetTokenExpiredError | NativeAuthStrategyError
+union NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError
+union AuthenticationResult = CurrentUser | InvalidCredentialsError

+ 0 - 4
packages/core/src/api/schema/type/auth.type.graphql

@@ -1,7 +1,3 @@
-type LoginResult {
-    user: CurrentUser!
-}
-
 type CurrentUser {
     id: ID!
     identifier: String!

+ 1 - 1
packages/core/src/common/error/error-result.ts

@@ -52,5 +52,5 @@ export function isGraphQlErrorResult<T extends GraphQLErrorResult | U, U = any>(
 export function isGraphQlErrorResult<T, E extends VendureEntity>(
     input: ErrorResultUnion<T, E>,
 ): input is JustErrorResults<ErrorResultUnion<T, E>> {
-    return !!((input as any).code && (input as any).message != null) && (input as any).__typename;
+    return input && !!((input as any).code && (input as any).message != null) && (input as any).__typename;
 }

+ 302 - 6
packages/core/src/common/error/generated-graphql-admin-errors.ts

@@ -1,8 +1,6 @@
 // tslint:disable
 /** This file was generated by the graphql-errors-plugin, which is part of the "codegen" npm script. */
 
-import { ErrorCode } from '@vendure/common/lib/generated-types';
-
 export type Scalars = {
   ID: string;
   String: string;
@@ -16,13 +14,111 @@ export type Scalars = {
 
 export class ErrorResult {
   readonly __typename: string;
-  readonly code: ErrorCode;
+  readonly code: string;
   message: Scalars['String'];
 }
 
+export class AlreadyRefundedError extends ErrorResult {
+  readonly __typename = 'AlreadyRefundedError';
+  readonly code = 'ALREADY_REFUNDED_ERROR' as any;
+  readonly message = 'ALREADY_REFUNDED_ERROR';
+  constructor(
+    public   refundId: Scalars['ID'],
+  ) {
+    super();
+  }
+}
+
+export class CancelActiveOrderError extends ErrorResult {
+  readonly __typename = 'CancelActiveOrderError';
+  readonly code = 'CANCEL_ACTIVE_ORDER_ERROR' as any;
+  readonly message = 'CANCEL_ACTIVE_ORDER_ERROR';
+  constructor(
+    public   orderState: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+export class ChannelDefaultLanguageError extends ErrorResult {
+  readonly __typename = 'ChannelDefaultLanguageError';
+  readonly code = 'CHANNEL_DEFAULT_LANGUAGE_ERROR' as any;
+  readonly message = 'CHANNEL_DEFAULT_LANGUAGE_ERROR';
+  constructor(
+    public   language: Scalars['String'],
+    public   channelCode: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+export class EmailAddressConflictError extends ErrorResult {
+  readonly __typename = 'EmailAddressConflictError';
+  readonly code = 'EMAIL_ADDRESS_CONFLICT_ERROR' as any;
+  readonly message = 'EMAIL_ADDRESS_CONFLICT_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class EmptyOrderLineSelectionError extends ErrorResult {
+  readonly __typename = 'EmptyOrderLineSelectionError';
+  readonly code = 'EMPTY_ORDER_LINE_SELECTION_ERROR' as any;
+  readonly message = 'EMPTY_ORDER_LINE_SELECTION_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class FulfillmentStateTransitionError extends ErrorResult {
+  readonly __typename = 'FulfillmentStateTransitionError';
+  readonly code = 'FULFILLMENT_STATE_TRANSITION_ERROR' as any;
+  readonly message = 'FULFILLMENT_STATE_TRANSITION_ERROR';
+  constructor(
+    public   transitionError: Scalars['String'],
+    public   fromState: Scalars['String'],
+    public   toState: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+export class InvalidCredentialsError extends ErrorResult {
+  readonly __typename = 'InvalidCredentialsError';
+  readonly code = 'INVALID_CREDENTIALS_ERROR' as any;
+  readonly message = 'INVALID_CREDENTIALS_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class ItemsAlreadyFulfilledError extends ErrorResult {
+  readonly __typename = 'ItemsAlreadyFulfilledError';
+  readonly code = 'ITEMS_ALREADY_FULFILLED_ERROR' as any;
+  readonly message = 'ITEMS_ALREADY_FULFILLED_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class LanguageNotAvailableError extends ErrorResult {
+  readonly __typename = 'LanguageNotAvailableError';
+  readonly code = 'LANGUAGE_NOT_AVAILABLE_ERROR' as any;
+  readonly message = 'LANGUAGE_NOT_AVAILABLE_ERROR';
+  constructor(
+    public   languageCode: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
 export class MimeTypeError extends ErrorResult {
   readonly __typename = 'MimeTypeError';
-  readonly code = ErrorCode.MIME_TYPE_ERROR;
+  readonly code = 'MIME_TYPE_ERROR' as any;
   readonly message = 'MIME_TYPE_ERROR';
   constructor(
     public   fileName: Scalars['String'],
@@ -32,9 +128,49 @@ export class MimeTypeError extends ErrorResult {
   }
 }
 
+export class MissingConditionsError extends ErrorResult {
+  readonly __typename = 'MissingConditionsError';
+  readonly code = 'MISSING_CONDITIONS_ERROR' as any;
+  readonly message = 'MISSING_CONDITIONS_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class MultipleOrderError extends ErrorResult {
+  readonly __typename = 'MultipleOrderError';
+  readonly code = 'MULTIPLE_ORDER_ERROR' as any;
+  readonly message = 'MULTIPLE_ORDER_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class NativeAuthStrategyError extends ErrorResult {
+  readonly __typename = 'NativeAuthStrategyError';
+  readonly code = 'NATIVE_AUTH_STRATEGY_ERROR' as any;
+  readonly message = 'NATIVE_AUTH_STRATEGY_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class NothingToRefundError extends ErrorResult {
+  readonly __typename = 'NothingToRefundError';
+  readonly code = 'NOTHING_TO_REFUND_ERROR' as any;
+  readonly message = 'NOTHING_TO_REFUND_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
 export class OrderStateTransitionError extends ErrorResult {
   readonly __typename = 'OrderStateTransitionError';
-  readonly code = ErrorCode.ORDER_STATE_TRANSITION_ERROR;
+  readonly code = 'ORDER_STATE_TRANSITION_ERROR' as any;
   readonly message = 'ORDER_STATE_TRANSITION_ERROR';
   constructor(
     public   transitionError: Scalars['String'],
@@ -45,8 +181,88 @@ export class OrderStateTransitionError extends ErrorResult {
   }
 }
 
+export class PaymentOrderMismatchError extends ErrorResult {
+  readonly __typename = 'PaymentOrderMismatchError';
+  readonly code = 'PAYMENT_ORDER_MISMATCH_ERROR' as any;
+  readonly message = 'PAYMENT_ORDER_MISMATCH_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
 
-const errorTypeNames = new Set(['MimeTypeError', 'OrderStateTransitionError']);
+export class PaymentStateTransitionError extends ErrorResult {
+  readonly __typename = 'PaymentStateTransitionError';
+  readonly code = 'PAYMENT_STATE_TRANSITION_ERROR' as any;
+  readonly message = 'PAYMENT_STATE_TRANSITION_ERROR';
+  constructor(
+    public   transitionError: Scalars['String'],
+    public   fromState: Scalars['String'],
+    public   toState: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+export class ProductOptionInUseError extends ErrorResult {
+  readonly __typename = 'ProductOptionInUseError';
+  readonly code = 'PRODUCT_OPTION_IN_USE_ERROR' as any;
+  readonly message = 'PRODUCT_OPTION_IN_USE_ERROR';
+  constructor(
+    public   optionGroupCode: Scalars['String'],
+    public   productVariantCount: Scalars['Int'],
+  ) {
+    super();
+  }
+}
+
+export class QuantityTooGreatError extends ErrorResult {
+  readonly __typename = 'QuantityTooGreatError';
+  readonly code = 'QUANTITY_TOO_GREAT_ERROR' as any;
+  readonly message = 'QUANTITY_TOO_GREAT_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class RefundOrderStateError extends ErrorResult {
+  readonly __typename = 'RefundOrderStateError';
+  readonly code = 'REFUND_ORDER_STATE_ERROR' as any;
+  readonly message = 'REFUND_ORDER_STATE_ERROR';
+  constructor(
+    public   orderState: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+export class RefundStateTransitionError extends ErrorResult {
+  readonly __typename = 'RefundStateTransitionError';
+  readonly code = 'REFUND_STATE_TRANSITION_ERROR' as any;
+  readonly message = 'REFUND_STATE_TRANSITION_ERROR';
+  constructor(
+    public   transitionError: Scalars['String'],
+    public   fromState: Scalars['String'],
+    public   toState: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+export class SettlePaymentError extends ErrorResult {
+  readonly __typename = 'SettlePaymentError';
+  readonly code = 'SETTLE_PAYMENT_ERROR' as any;
+  readonly message = 'SETTLE_PAYMENT_ERROR';
+  constructor(
+    public   paymentErrorMessage: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+
+const errorTypeNames = new Set(['MimeTypeError', 'InvalidCredentialsError', 'NativeAuthStrategyError', 'LanguageNotAvailableError', 'EmailAddressConflictError', 'ChannelDefaultLanguageError', 'SettlePaymentError', 'PaymentStateTransitionError', 'OrderStateTransitionError', 'EmptyOrderLineSelectionError', 'ItemsAlreadyFulfilledError', 'QuantityTooGreatError', 'MultipleOrderError', 'CancelActiveOrderError', 'NothingToRefundError', 'PaymentOrderMismatchError', 'RefundOrderStateError', 'AlreadyRefundedError', 'RefundStateTransitionError', 'FulfillmentStateTransitionError', 'ProductOptionInUseError', 'MissingConditionsError']);
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
 }
@@ -57,9 +273,89 @@ export const adminErrorOperationTypeResolvers = {
       return isGraphQLError(value) ? (value as any).__typename : 'Asset';
     },
   },
+  NativeAuthenticationResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'CurrentUser';
+    },
+  },
+  AuthenticationResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'CurrentUser';
+    },
+  },
+  CreateChannelResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Channel';
+    },
+  },
+  UpdateChannelResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Channel';
+    },
+  },
+  CreateCustomerResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Customer';
+    },
+  },
+  UpdateCustomerResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Customer';
+    },
+  },
+  UpdateGlobalSettingsResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'GlobalSettings';
+    },
+  },
+  SettlePaymentResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Payment';
+    },
+  },
+  AddFulfillmentToOrderResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Fulfillment';
+    },
+  },
+  CancelOrderResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Order';
+    },
+  },
+  RefundOrderResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Refund';
+    },
+  },
+  SettleRefundResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Refund';
+    },
+  },
   TransitionOrderToStateResult: {
     __resolveType(value: any) {
       return isGraphQLError(value) ? (value as any).__typename : 'Order';
     },
   },
+  TransitionFulfillmentToStateResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Fulfillment';
+    },
+  },
+  RemoveOptionGroupFromProductResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Product';
+    },
+  },
+  CreatePromotionResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Promotion';
+    },
+  },
+  UpdatePromotionResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Promotion';
+    },
+  },
 };

+ 34 - 26
packages/core/src/common/error/generated-graphql-shop-errors.ts

@@ -1,8 +1,6 @@
 // tslint:disable
 /** This file was generated by the graphql-errors-plugin, which is part of the "codegen" npm script. */
 
-import { ErrorCode } from '@vendure/common/lib/generated-shop-types';
-
 export type Scalars = {
   ID: string;
   String: string;
@@ -16,13 +14,13 @@ export type Scalars = {
 
 export class ErrorResult {
   readonly __typename: string;
-  readonly code: ErrorCode;
+  readonly code: string;
   message: Scalars['String'];
 }
 
 export class AlreadyLoggedInError extends ErrorResult {
   readonly __typename = 'AlreadyLoggedInError';
-  readonly code = ErrorCode.ALREADY_LOGGED_IN_ERROR;
+  readonly code = 'ALREADY_LOGGED_IN_ERROR' as any;
   readonly message = 'ALREADY_LOGGED_IN_ERROR';
   constructor(
   ) {
@@ -32,7 +30,7 @@ export class AlreadyLoggedInError extends ErrorResult {
 
 export class CouponCodeExpiredError extends ErrorResult {
   readonly __typename = 'CouponCodeExpiredError';
-  readonly code = ErrorCode.COUPON_CODE_EXPIRED_ERROR;
+  readonly code = 'COUPON_CODE_EXPIRED_ERROR' as any;
   readonly message = 'COUPON_CODE_EXPIRED_ERROR';
   constructor(
     public   couponCode: Scalars['String'],
@@ -43,7 +41,7 @@ export class CouponCodeExpiredError extends ErrorResult {
 
 export class CouponCodeInvalidError extends ErrorResult {
   readonly __typename = 'CouponCodeInvalidError';
-  readonly code = ErrorCode.COUPON_CODE_INVALID_ERROR;
+  readonly code = 'COUPON_CODE_INVALID_ERROR' as any;
   readonly message = 'COUPON_CODE_INVALID_ERROR';
   constructor(
     public   couponCode: Scalars['String'],
@@ -54,7 +52,7 @@ export class CouponCodeInvalidError extends ErrorResult {
 
 export class CouponCodeLimitError extends ErrorResult {
   readonly __typename = 'CouponCodeLimitError';
-  readonly code = ErrorCode.COUPON_CODE_LIMIT_ERROR;
+  readonly code = 'COUPON_CODE_LIMIT_ERROR' as any;
   readonly message = 'COUPON_CODE_LIMIT_ERROR';
   constructor(
     public   couponCode: Scalars['String'],
@@ -66,7 +64,7 @@ export class CouponCodeLimitError extends ErrorResult {
 
 export class EmailAddressConflictError extends ErrorResult {
   readonly __typename = 'EmailAddressConflictError';
-  readonly code = ErrorCode.EMAIL_ADDRESS_CONFLICT_ERROR;
+  readonly code = 'EMAIL_ADDRESS_CONFLICT_ERROR' as any;
   readonly message = 'EMAIL_ADDRESS_CONFLICT_ERROR';
   constructor(
   ) {
@@ -76,7 +74,7 @@ export class EmailAddressConflictError extends ErrorResult {
 
 export class IdentifierChangeTokenExpiredError extends ErrorResult {
   readonly __typename = 'IdentifierChangeTokenExpiredError';
-  readonly code = ErrorCode.IDENTIFIER_CHANGE_TOKEN_EXPIRED_ERROR;
+  readonly code = 'IDENTIFIER_CHANGE_TOKEN_EXPIRED_ERROR' as any;
   readonly message = 'IDENTIFIER_CHANGE_TOKEN_EXPIRED_ERROR';
   constructor(
   ) {
@@ -86,7 +84,7 @@ export class IdentifierChangeTokenExpiredError extends ErrorResult {
 
 export class IdentifierChangeTokenInvalidError extends ErrorResult {
   readonly __typename = 'IdentifierChangeTokenInvalidError';
-  readonly code = ErrorCode.IDENTIFIER_CHANGE_TOKEN_INVALID_ERROR;
+  readonly code = 'IDENTIFIER_CHANGE_TOKEN_INVALID_ERROR' as any;
   readonly message = 'IDENTIFIER_CHANGE_TOKEN_INVALID_ERROR';
   constructor(
   ) {
@@ -96,7 +94,7 @@ export class IdentifierChangeTokenInvalidError extends ErrorResult {
 
 export class InvalidCredentialsError extends ErrorResult {
   readonly __typename = 'InvalidCredentialsError';
-  readonly code = ErrorCode.INVALID_CREDENTIALS_ERROR;
+  readonly code = 'INVALID_CREDENTIALS_ERROR' as any;
   readonly message = 'INVALID_CREDENTIALS_ERROR';
   constructor(
   ) {
@@ -106,7 +104,7 @@ export class InvalidCredentialsError extends ErrorResult {
 
 export class MissingPasswordError extends ErrorResult {
   readonly __typename = 'MissingPasswordError';
-  readonly code = ErrorCode.MISSING_PASSWORD_ERROR;
+  readonly code = 'MISSING_PASSWORD_ERROR' as any;
   readonly message = 'MISSING_PASSWORD_ERROR';
   constructor(
   ) {
@@ -116,7 +114,7 @@ export class MissingPasswordError extends ErrorResult {
 
 export class NativeAuthStrategyError extends ErrorResult {
   readonly __typename = 'NativeAuthStrategyError';
-  readonly code = ErrorCode.NATIVE_AUTH_STRATEGY_ERROR;
+  readonly code = 'NATIVE_AUTH_STRATEGY_ERROR' as any;
   readonly message = 'NATIVE_AUTH_STRATEGY_ERROR';
   constructor(
   ) {
@@ -126,7 +124,7 @@ export class NativeAuthStrategyError extends ErrorResult {
 
 export class NegativeQuantityError extends ErrorResult {
   readonly __typename = 'NegativeQuantityError';
-  readonly code = ErrorCode.NEGATIVE_QUANTITY_ERROR;
+  readonly code = 'NEGATIVE_QUANTITY_ERROR' as any;
   readonly message = 'NEGATIVE_QUANTITY_ERROR';
   constructor(
   ) {
@@ -136,7 +134,7 @@ export class NegativeQuantityError extends ErrorResult {
 
 export class OrderLimitError extends ErrorResult {
   readonly __typename = 'OrderLimitError';
-  readonly code = ErrorCode.ORDER_LIMIT_ERROR;
+  readonly code = 'ORDER_LIMIT_ERROR' as any;
   readonly message = 'ORDER_LIMIT_ERROR';
   constructor(
     public   maxItems: Scalars['Int'],
@@ -147,7 +145,7 @@ export class OrderLimitError extends ErrorResult {
 
 export class OrderModificationError extends ErrorResult {
   readonly __typename = 'OrderModificationError';
-  readonly code = ErrorCode.ORDER_MODIFICATION_ERROR;
+  readonly code = 'ORDER_MODIFICATION_ERROR' as any;
   readonly message = 'ORDER_MODIFICATION_ERROR';
   constructor(
   ) {
@@ -157,7 +155,7 @@ export class OrderModificationError extends ErrorResult {
 
 export class OrderPaymentStateError extends ErrorResult {
   readonly __typename = 'OrderPaymentStateError';
-  readonly code = ErrorCode.ORDER_PAYMENT_STATE_ERROR;
+  readonly code = 'ORDER_PAYMENT_STATE_ERROR' as any;
   readonly message = 'ORDER_PAYMENT_STATE_ERROR';
   constructor(
   ) {
@@ -167,7 +165,7 @@ export class OrderPaymentStateError extends ErrorResult {
 
 export class OrderStateTransitionError extends ErrorResult {
   readonly __typename = 'OrderStateTransitionError';
-  readonly code = ErrorCode.ORDER_STATE_TRANSITION_ERROR;
+  readonly code = 'ORDER_STATE_TRANSITION_ERROR' as any;
   readonly message = 'ORDER_STATE_TRANSITION_ERROR';
   constructor(
     public   transitionError: Scalars['String'],
@@ -180,7 +178,7 @@ export class OrderStateTransitionError extends ErrorResult {
 
 export class PasswordAlreadySetError extends ErrorResult {
   readonly __typename = 'PasswordAlreadySetError';
-  readonly code = ErrorCode.PASSWORD_ALREADY_SET_ERROR;
+  readonly code = 'PASSWORD_ALREADY_SET_ERROR' as any;
   readonly message = 'PASSWORD_ALREADY_SET_ERROR';
   constructor(
   ) {
@@ -190,7 +188,7 @@ export class PasswordAlreadySetError extends ErrorResult {
 
 export class PasswordResetTokenExpiredError extends ErrorResult {
   readonly __typename = 'PasswordResetTokenExpiredError';
-  readonly code = ErrorCode.PASSWORD_RESET_TOKEN_EXPIRED_ERROR;
+  readonly code = 'PASSWORD_RESET_TOKEN_EXPIRED_ERROR' as any;
   readonly message = 'PASSWORD_RESET_TOKEN_EXPIRED_ERROR';
   constructor(
   ) {
@@ -200,7 +198,7 @@ export class PasswordResetTokenExpiredError extends ErrorResult {
 
 export class PasswordResetTokenInvalidError extends ErrorResult {
   readonly __typename = 'PasswordResetTokenInvalidError';
-  readonly code = ErrorCode.PASSWORD_RESET_TOKEN_INVALID_ERROR;
+  readonly code = 'PASSWORD_RESET_TOKEN_INVALID_ERROR' as any;
   readonly message = 'PASSWORD_RESET_TOKEN_INVALID_ERROR';
   constructor(
   ) {
@@ -210,7 +208,7 @@ export class PasswordResetTokenInvalidError extends ErrorResult {
 
 export class PaymentDeclinedError extends ErrorResult {
   readonly __typename = 'PaymentDeclinedError';
-  readonly code = ErrorCode.PAYMENT_DECLINED_ERROR;
+  readonly code = 'PAYMENT_DECLINED_ERROR' as any;
   readonly message = 'PAYMENT_DECLINED_ERROR';
   constructor(
     public   paymentErrorMessage: Scalars['String'],
@@ -221,7 +219,7 @@ export class PaymentDeclinedError extends ErrorResult {
 
 export class PaymentFailedError extends ErrorResult {
   readonly __typename = 'PaymentFailedError';
-  readonly code = ErrorCode.PAYMENT_FAILED_ERROR;
+  readonly code = 'PAYMENT_FAILED_ERROR' as any;
   readonly message = 'PAYMENT_FAILED_ERROR';
   constructor(
     public   paymentErrorMessage: Scalars['String'],
@@ -232,7 +230,7 @@ export class PaymentFailedError extends ErrorResult {
 
 export class VerificationTokenExpiredError extends ErrorResult {
   readonly __typename = 'VerificationTokenExpiredError';
-  readonly code = ErrorCode.VERIFICATION_TOKEN_EXPIRED_ERROR;
+  readonly code = 'VERIFICATION_TOKEN_EXPIRED_ERROR' as any;
   readonly message = 'VERIFICATION_TOKEN_EXPIRED_ERROR';
   constructor(
   ) {
@@ -242,7 +240,7 @@ export class VerificationTokenExpiredError extends ErrorResult {
 
 export class VerificationTokenInvalidError extends ErrorResult {
   readonly __typename = 'VerificationTokenInvalidError';
-  readonly code = ErrorCode.VERIFICATION_TOKEN_INVALID_ERROR;
+  readonly code = 'VERIFICATION_TOKEN_INVALID_ERROR' as any;
   readonly message = 'VERIFICATION_TOKEN_INVALID_ERROR';
   constructor(
   ) {
@@ -251,7 +249,7 @@ export class VerificationTokenInvalidError extends ErrorResult {
 }
 
 
-const errorTypeNames = new Set(['OrderModificationError', 'OrderLimitError', 'NegativeQuantityError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'OrderStateTransitionError', 'OrderPaymentStateError', 'PaymentFailedError', 'PaymentDeclinedError', 'AlreadyLoggedInError', 'EmailAddressConflictError', 'MissingPasswordError', 'NativeAuthStrategyError', 'VerificationTokenInvalidError', 'VerificationTokenExpiredError', 'PasswordAlreadySetError', 'InvalidCredentialsError', 'IdentifierChangeTokenInvalidError', 'IdentifierChangeTokenExpiredError', 'PasswordResetTokenInvalidError', 'PasswordResetTokenExpiredError']);
+const errorTypeNames = new Set(['OrderModificationError', 'OrderLimitError', 'NegativeQuantityError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'OrderStateTransitionError', 'OrderPaymentStateError', 'PaymentFailedError', 'PaymentDeclinedError', 'AlreadyLoggedInError', 'EmailAddressConflictError', 'InvalidCredentialsError', 'NativeAuthStrategyError', 'MissingPasswordError', 'VerificationTokenInvalidError', 'VerificationTokenExpiredError', 'PasswordAlreadySetError', 'IdentifierChangeTokenInvalidError', 'IdentifierChangeTokenExpiredError', 'PasswordResetTokenInvalidError', 'PasswordResetTokenExpiredError']);
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
 }
@@ -292,6 +290,16 @@ export const shopErrorOperationTypeResolvers = {
       return isGraphQLError(value) ? (value as any).__typename : 'Order';
     },
   },
+  NativeAuthenticationResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'CurrentUser';
+    },
+  },
+  AuthenticationResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'CurrentUser';
+    },
+  },
   RegisterCustomerAccountResult: {
     __resolveType(value: any) {
       return isGraphQLError(value) ? (value as any).__typename : 'Success';

+ 1 - 0
packages/core/src/common/index.ts

@@ -2,6 +2,7 @@ export * from './finite-state-machine/finite-state-machine';
 export * from './finite-state-machine/types';
 export * from './async-queue';
 export * from './error/errors';
+export * from './error/error-result';
 export * from './error/generated-graphql-admin-errors';
 export * from './injector';
 export * from './ttl-cache';

+ 5 - 6
packages/core/src/config/auth/native-authentication-strategy.ts

@@ -49,6 +49,9 @@ export class NativeAuthenticationStrategy implements AuthenticationStrategy<Nati
 
     async authenticate(ctx: RequestContext, data: NativeAuthenticationData): Promise<User | false> {
         const user = await this.getUserFromIdentifier(ctx, data.username);
+        if (!user) {
+            return false;
+        }
         const passwordMatch = await this.verifyUserPassword(ctx, user.id, data.password);
         if (!passwordMatch) {
             return false;
@@ -56,15 +59,11 @@ export class NativeAuthenticationStrategy implements AuthenticationStrategy<Nati
         return user;
     }
 
-    private async getUserFromIdentifier(ctx: RequestContext, identifier: string): Promise<User> {
-        const user = await this.connection.getRepository(ctx, User).findOne({
+    private getUserFromIdentifier(ctx: RequestContext, identifier: string): Promise<User | undefined> {
+        return this.connection.getRepository(ctx, User).findOne({
             where: { identifier },
             relations: ['roles', 'roles.channels'],
         });
-        if (!user) {
-            throw new UnauthorizedError();
-        }
-        return user;
     }
 
     /**

+ 15 - 28
packages/core/src/i18n/messages/en.json

@@ -1,30 +1,18 @@
 {
   "error": {
-    "cancel-order-lines-invalid-order-state": "Cannot cancel OrderLines from an Order in the \"{ state }\" state",
-    "cancel-order-lines-nothing-to-cancel": "Nothing to cancel",
-    "cancel-order-lines-quantity-too-high": "Quantity to cancel is greater than existing OrderLine quantity",
     "cannot-delete-role": "The role '{ roleCode }' cannot be deleted",
     "cannot-locate-customer-for-user": "Cannot locate a Customer for the user",
     "cannot-modify-role": "The role '{ roleCode }' cannot be modified",
     "cannot-create-sales-for-active-order": "Cannot create a Sale for an Order which is still active",
     "cannot-move-collection-into-self": "Cannot move a Collection into itself",
-    "cannot-remove-option-group-due-to-variants": "Cannot remove ProductOptionGroup \"{ code }\" as it is used by {count, plural, one {1 ProductVariant} other {# ProductVariants}}",
-    "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-set-default-language-as-unavailable": "Cannot remove make language \"{ language }\" unavailable as it is used as the defaultLanguage by the channel \"{channelCode}\"",
     "cannot-transition-payment-from-to": "Cannot transition Payment from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-refund-from-to": "Cannot transition Refund from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-fulfillment-from-to": "Cannot transition Fulfillment from \"{ fromState }\" to \"{ toState }\"",
-    "channel-not-found": "No channel with the token \"{ token }\" exists",
     "collection-id-or-slug-must-be-provided": "Either the Collection id or slug must be provided",
     "collection-id-slug-mismatch": "The provided id and slug refer to different Collections",
     "country-code-not-valid": "The countryCode \"{ countryCode }\" was not recognized",
-    "create-fulfillment-items-already-fulfilled": "One or more OrderItems have already been fulfilled",
-    "create-fulfillment-orders-must-be-settled": "One or more OrderItems belong to an Order which is in an invalid state",
-    "create-fulfillment-nothing-to-fulfill": "Nothing to fulfill",
     "customer-does-not-belong-to-customer-group": "Customer does not belong to this CustomerGroup",
     "default-channel-not-found": "Default channel not found",
-    "email-address-must-be-unique": "The email address must be unique",
-    "email-address-not-available": "This email address is not available",
     "email-address-not-verified": "Please verify this email address before logging in",
     "entity-has-no-translation-in-language": "Translatable entity '{ entityName }' has not been translated into the requested language ({ languageCode })",
     "entity-with-id-not-found": "No { entityName } with the id '{ id }' could be found",
@@ -37,18 +25,10 @@
     "field-invalid-string-option": "The custom field '{ name }' value ['{ value }'] is invalid. Valid options are [{ validOptions }]",
     "field-invalid-string-pattern": "The custom field '{ name }' value ['{ value }'] does not match the pattern [{ pattern }]",
     "forbidden": "You are not currently authorized to perform this action",
-    "identifier-change-token-has-expired": "Identifier change token has expired",
     "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }",
-    "language-not-available-in-global-settings": "Language \"{code}\" is not available. First enable it via GlobalSettings and try again.",
     "no-active-tax-zone": "The active tax zone could not be determined. Ensure a default tax zone is set for the current channel.",
     "no-search-plugin-configured": "No search plugin has been configured",
-    "no-valid-channel-specified": "No valid channel was specified (ensure the 'vendure-token' header was specified in the request)",
     "order-does-not-contain-line-with-id": "This order does not contain an OrderLine with the id { id }",
-    "order-lines-must-belong-to-same-order": "OrderLines must all belong to a single Order",
-    "password-already-set-during-registration": "A password has already been set during registration",
-    "password-reset-token-has-expired": "Password reset token has expired.",
-    "password-reset-token-not-recognized": "Password reset token not recognized",
-    "password-required-for-verification": "A password must be provided as it was not set during registration",
     "pending-identifier-missing": "Could not find the pending email address to update",
     "permission-invalid": "The permission \"{ permission }\" is not valid",
     "products-cannot-be-removed-from-default-channel": "Products cannot be removed from the default Channel",
@@ -56,28 +36,29 @@
     "product-id-slug-mismatch": "The provided id and slug refer to different Products",
     "product-variant-option-ids-not-compatible": "ProductVariant optionIds must include one optionId from each of the groups: {groupNames}",
     "product-variant-options-combination-already-exists": "A ProductVariant already exists with the options: {optionNames}",
-    "promotion-must-have-conditions-or-coupon-code": "A Promotion must have either at least one condition or a coupon code set",
-    "refund-order-item-already-refunded": "Cannot refund an OrderItem which has already been refunded",
-    "refund-order-lines-invalid-order-state": "Cannot refund an Order in the \"{ state }\" state",
-    "refund-order-lines-nothing-to-refund": "Nothing to refund",
-    "refund-order-lines-quantity-too-high": "Quantity to refund is greater than existing OrderLine quantity",
-    "refund-order-payment-lines-mismatch": "The Payment and OrderLines do not belong to the same Order",
     "stockonhand-cannot-be-negative": "stockOnHand cannot be a negative value",
-    "verification-token-has-expired": "Verification token has expired. Use refreshCustomerVerification to send a new token.",
-    "verification-token-not-recognized": "Verification token not recognized",
     "unauthorized": "The credentials did not match. Please check and try again"
   },
   "errorResult": {
+    "ALREADY_CANCELLED_ERROR": "Quantity to cancel is greater than existing OrderLine quantity",
     "ALREADY_LOGGED_IN_ERROR": "Cannot set a Customer for the Order when already logged in",
+    "ALREADY_REFUNDED_ERROR": "Cannot refund an OrderItem which has already been refunded",
+    "CANCEL_ACTIVE_ORDER_ERROR": "Cannot cancel OrderLines from an Order in the \"{ orderState }\" state",
+    "CHANNEL_DEFAULT_LANGUAGE_ERROR": "Cannot make language \"{ language }\" unavailable as it is used as the defaultLanguage by the channel \"{ channelCode }\"",
     "COUPON_CODE_EXPIRED_ERROR": "Coupon code \"{ couponCode }\" has expired",
     "COUPON_CODE_INVALID_ERROR": "Coupon code \"{ couponCode }\" is not valid",
     "COUPON_CODE_LIMIT_ERROR": "Coupon code cannot be used more than {limit, plural, one {once} other {# times}} per customer",
     "EMAIL_ADDRESS_CONFLICT_ERROR": "The email address is not available.",
+    "EMPTY_ORDER_LINE_SELECTION_ERROR": "At least one OrderLine must be specified",
     "IDENTIFIER_CHANGE_TOKEN_INVALID_ERROR": "Identifier change token not recognized",
     "INVALID_CREDENTIALS_ERROR": "The provided credentials are invalid",
+    "ITEMS_ALREADY_FULFILLED_ERROR": "One or more OrderItems are already part of a Fulfillment",
+    "LANGUAGE_NOT_AVAILABLE_ERROR": "Language \"{languageCode}\" is not available. First enable it via GlobalSettings and try again",
     "MIME_TYPE_ERROR": "The MIME type '{ mimeType }' is not permitted.",
+    "MISSING_CONDITIONS_ERROR": "A Promotion must have either at least one condition or a coupon code set",
     "MISSING_PASSWORD_ERROR": "A password must be provided.",
     "NEGATIVE_QUANTITY_ERROR": "The quantity for an OrderItem cannot be negative",
+    "NOTHING_TO_REFUND_ERROR": "Nothing to refund",
     "ORDER_LIMIT_ERROR": "Cannot add items. An order may consist of a maximum of { maxItems } items",
     "ORDER_MODIFICATION_ERROR": "Order contents may only be modified when in the \"AddingItems\" state",
     "ORDER_PAYMENT_STATE_ERROR": "A Payment may only be added when Order is in \"ArrangingPayment\" state",
@@ -87,11 +68,17 @@
     "PASSWORD_RESET_TOKEN_INVALID_ERROR": "Password reset token not recognized",
     "PAYMENT_DECLINED_ERROR": "The payment was declined",
     "PAYMENT_FAILED_ERROR": "The payment failed",
+    "PAYMENT_ORDER_MISMATCH_ERROR": "The Payment and OrderLines do not belong to the same Order",
+    "PRODUCT_OPTION_IN_USE_ERROR": "Cannot remove ProductOptionGroup \"{ optionGroupCode }\" as it is used by {productVariantCount, plural, one {1 ProductVariant} other {# ProductVariants}}",
+    "QUANTITY_TOO_GREAT_ERROR": "The specified quantity is greater than the available OrderItems",
+    "REFUND_ORDER_STATE_ERROR": "Cannot refund an Order in the \"{ orderState }\" state",
+    "SETTLE_PAYMENT_ERROR": "Settling the payment failed",
     "VERIFICATION_TOKEN_EXPIRED_ERROR": "Verification token has expired. Use refreshCustomerVerification to send a new token.",
     "VERIFICATION_TOKEN_INVALID_ERROR": "Verification token not recognized"
   },
   "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-order-from-to": "Cannot transition Order from \"{ fromState }\" to \"{ toState }\"",
     "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",

+ 6 - 5
packages/core/src/service/services/auth.service.ts

@@ -3,8 +3,9 @@ import { ID } from '@vendure/common/lib/shared-types';
 
 import { ApiType } from '../../api/common/get-api-type';
 import { RequestContext } from '../../api/common/request-context';
-import { InternalServerError, NotVerifiedError, UnauthorizedError } from '../../common/error/errors';
-import { InvalidCredentialsError } from '../../common/error/generated-graphql-shop-errors';
+import { InternalServerError, NotVerifiedError } from '../../common/error/errors';
+import { InvalidCredentialsError } from '../../common/error/generated-graphql-admin-errors';
+import { InvalidCredentialsError as ShopInvalidCredentialsError } from '../../common/error/generated-graphql-shop-errors';
 import { AuthenticationStrategy } from '../../config/auth/authentication-strategy';
 import {
     NATIVE_AUTH_STRATEGY_NAME,
@@ -42,7 +43,7 @@ export class AuthService {
         apiType: ApiType,
         authenticationMethod: string,
         authenticationData: any,
-    ): Promise<AuthenticatedSession> {
+    ): Promise<AuthenticatedSession | InvalidCredentialsError> {
         this.eventBus.publish(
             new AttemptedLoginEvent(
                 ctx,
@@ -55,7 +56,7 @@ export class AuthService {
         const authenticationStrategy = this.getAuthenticationStrategy(apiType, authenticationMethod);
         const user = await authenticationStrategy.authenticate(ctx, authenticationData);
         if (!user) {
-            throw new UnauthorizedError();
+            return new InvalidCredentialsError();
         }
         return this.createAuthenticatedSessionForUser(ctx, user, authenticationStrategy.name);
     }
@@ -100,7 +101,7 @@ export class AuthService {
         ctx: RequestContext,
         userId: ID,
         password: string,
-    ): Promise<boolean | InvalidCredentialsError> {
+    ): Promise<boolean | InvalidCredentialsError | ShopInvalidCredentialsError> {
         const nativeAuthenticationStrategy = this.getAuthenticationStrategy(
             'shop',
             NATIVE_AUTH_STRATEGY_NAME,

+ 34 - 17
packages/core/src/service/services/channel.service.ts

@@ -1,22 +1,21 @@
 import { Injectable } from '@nestjs/common';
 import {
     CreateChannelInput,
+    CreateChannelResult,
     CurrencyCode,
     DeletionResponse,
     DeletionResult,
     UpdateChannelInput,
+    UpdateChannelResult,
 } from '@vendure/common/lib/generated-types';
 import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { ID, Type } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../api/common/request-context';
-import {
-    ChannelNotFoundError,
-    EntityNotFoundError,
-    InternalServerError,
-    UserInputError,
-} from '../../common/error/errors';
+import { ErrorResultUnion, isGraphQlErrorResult } from '../../common/error/error-result';
+import { ChannelNotFoundError, EntityNotFoundError, InternalServerError } from '../../common/error/errors';
+import { LanguageNotAvailableError } from '../../common/error/generated-graphql-admin-errors';
 import { ChannelAware } from '../../common/types/common-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
@@ -136,8 +135,15 @@ export class ChannelService {
             .findOne(id, { relations: ['defaultShippingZone', 'defaultTaxZone'] });
     }
 
-    async create(ctx: RequestContext, input: CreateChannelInput): Promise<Channel> {
+    async create(
+        ctx: RequestContext,
+        input: CreateChannelInput,
+    ): Promise<ErrorResultUnion<CreateChannelResult, Channel>> {
         const channel = new Channel(input);
+        const defaultLanguageValidationResult = await this.validateDefaultLanguageCode(ctx, input);
+        if (isGraphQlErrorResult(defaultLanguageValidationResult)) {
+            return defaultLanguageValidationResult;
+        }
         if (input.defaultTaxZoneId) {
             channel.defaultTaxZone = await this.connection.getEntityOrThrow(
                 ctx,
@@ -157,20 +163,17 @@ export class ChannelService {
         return channel;
     }
 
-    async update(ctx: RequestContext, input: UpdateChannelInput): Promise<Channel> {
+    async update(
+        ctx: RequestContext,
+        input: UpdateChannelInput,
+    ): Promise<ErrorResultUnion<UpdateChannelResult, Channel>> {
         const channel = await this.findOne(ctx, input.id);
         if (!channel) {
             throw new EntityNotFoundError('Channel', input.id);
         }
-        if (input.defaultLanguageCode) {
-            const availableLanguageCodes = await this.globalSettingsService
-                .getSettings(ctx)
-                .then(s => s.availableLanguages);
-            if (!availableLanguageCodes.includes(input.defaultLanguageCode)) {
-                throw new UserInputError('error.language-not-available-in-global-settings', {
-                    code: input.defaultLanguageCode,
-                });
-            }
+        const defaultLanguageValidationResult = await this.validateDefaultLanguageCode(ctx, input);
+        if (isGraphQlErrorResult(defaultLanguageValidationResult)) {
+            return defaultLanguageValidationResult;
         }
         const updatedChannel = patchEntity(channel, input);
         if (input.defaultTaxZoneId) {
@@ -230,6 +233,20 @@ export class ChannelService {
         }
     }
 
+    private async validateDefaultLanguageCode(
+        ctx: RequestContext,
+        input: CreateChannelInput | UpdateChannelInput,
+    ): Promise<LanguageNotAvailableError | undefined> {
+        if (input.defaultLanguageCode) {
+            const availableLanguageCodes = await this.globalSettingsService
+                .getSettings(ctx)
+                .then(s => s.availableLanguages);
+            if (!availableLanguageCodes.includes(input.defaultLanguageCode)) {
+                return new LanguageNotAvailableError(input.defaultLanguageCode);
+            }
+        }
+    }
+
     private async updateAllChannels(ctx?: RequestContext) {
         this.allChannels = await this.findAll(ctx || RequestContext.empty());
     }

+ 59 - 28
packages/core/src/service/services/customer.service.ts

@@ -2,29 +2,28 @@ import { Injectable } from '@nestjs/common';
 import {
     RegisterCustomerAccountResult,
     RegisterCustomerInput,
+    UpdateCustomerInput as UpdateCustomerShopInput,
     VerifyCustomerAccountResult,
 } from '@vendure/common/lib/generated-shop-types';
 import {
     AddNoteToCustomerInput,
     CreateAddressInput,
     CreateCustomerInput,
+    CreateCustomerResult,
     DeletionResponse,
     DeletionResult,
     HistoryEntryType,
     UpdateAddressInput,
     UpdateCustomerInput,
     UpdateCustomerNoteInput,
+    UpdateCustomerResult,
 } from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
 import { ErrorResultUnion, isGraphQlErrorResult } from '../../common/error/error-result';
-import {
-    EntityNotFoundError,
-    IllegalOperationError,
-    InternalServerError,
-    UserInputError,
-} from '../../common/error/errors';
+import { EntityNotFoundError, InternalServerError } from '../../common/error/errors';
+import { EmailAddressConflictError as EmailAddressConflictAdminError } from '../../common/error/generated-graphql-admin-errors';
 import {
     EmailAddressConflictError,
     IdentifierChangeTokenExpiredError,
@@ -32,7 +31,6 @@ import {
     MissingPasswordError,
     PasswordResetTokenExpiredError,
     PasswordResetTokenInvalidError,
-    VerificationTokenInvalidError,
 } from '../../common/error/generated-graphql-shop-errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound, idsAreEqual, normalizeEmailAddress } from '../../common/utils';
@@ -145,7 +143,11 @@ export class CustomerService {
         }
     }
 
-    async create(ctx: RequestContext, input: CreateCustomerInput, password?: string): Promise<Customer> {
+    async create(
+        ctx: RequestContext,
+        input: CreateCustomerInput,
+        password?: string,
+    ): Promise<ErrorResultUnion<CreateCustomerResult, Customer>> {
         input.emailAddress = normalizeEmailAddress(input.emailAddress);
         const customer = new Customer(input);
 
@@ -159,7 +161,7 @@ export class CustomerService {
             .getOne();
 
         if (existingCustomerInChannel) {
-            throw new UserInputError(`error.email-address-must-be-unique`);
+            return new EmailAddressConflictAdminError();
         }
 
         const existingCustomer = await this.connection.getRepository(ctx, Customer).findOne({
@@ -183,7 +185,7 @@ export class CustomerService {
             return this.connection.getRepository(Customer).save(updatedCustomer);
         } else if (existingCustomer || existingUser) {
             // Not sure when this situation would occur
-            throw new UserInputError(`error.email-address-must-be-unique`);
+            return new EmailAddressConflictAdminError();
         }
         customer.user = await this.userService.createCustomerUser(ctx, input.emailAddress, password);
 
@@ -192,7 +194,9 @@ export class CustomerService {
             if (verificationToken) {
                 const result = await this.userService.verifyUserByToken(ctx, verificationToken);
                 if (isGraphQlErrorResult(result)) {
-                    // TODO: what to do with an error result here?
+                    // In theory this should never be reached, so we will just
+                    // throw the result
+                    throw result;
                 } else {
                     customer.user = result;
                 }
@@ -225,6 +229,50 @@ export class CustomerService {
         return createdCustomer;
     }
 
+    async update(ctx: RequestContext, input: UpdateCustomerShopInput & { id: ID }): Promise<Customer>;
+    async update(
+        ctx: RequestContext,
+        input: UpdateCustomerInput,
+    ): Promise<ErrorResultUnion<UpdateCustomerResult, Customer>>;
+    async update(
+        ctx: RequestContext,
+        input: UpdateCustomerInput | (UpdateCustomerShopInput & { id: ID }),
+    ): Promise<ErrorResultUnion<UpdateCustomerResult, Customer>> {
+        const hasEmailAddress = (i: any): i is UpdateCustomerInput & { emailAddress: string } =>
+            i.hasOwnProperty('emailAddress');
+
+        if (hasEmailAddress(input)) {
+            const existingCustomerInChannel = await this.connection
+                .getRepository(ctx, Customer)
+                .createQueryBuilder('customer')
+                .leftJoin('customer.channels', 'channel')
+                .where('channel.id = :channelId', { channelId: ctx.channelId })
+                .andWhere('customer.emailAddress = :emailAddress', { emailAddress: input.emailAddress })
+                .andWhere('customer.id != :customerId', { customerId: input.id })
+                .andWhere('customer.deletedAt is null')
+                .getOne();
+
+            if (existingCustomerInChannel) {
+                return new EmailAddressConflictAdminError();
+            }
+        }
+
+        const customer = await this.connection.getEntityOrThrow(ctx, Customer, input.id, {
+            channelId: ctx.channelId,
+        });
+        const updatedCustomer = patchEntity(customer, input);
+        await this.connection.getRepository(ctx, Customer).save(updatedCustomer, { reload: false });
+        await this.historyService.createHistoryEntryForCustomer({
+            customerId: customer.id,
+            ctx,
+            type: HistoryEntryType.CUSTOMER_DETAIL_UPDATED,
+            data: {
+                input,
+            },
+        });
+        return assertFound(this.findOne(ctx, customer.id));
+    }
+
     async registerCustomerAccount(
         ctx: RequestContext,
         input: RegisterCustomerInput,
@@ -459,23 +507,6 @@ export class CustomerService {
         return true;
     }
 
-    async update(ctx: RequestContext, input: UpdateCustomerInput): Promise<Customer> {
-        const customer = await this.connection.getEntityOrThrow(ctx, Customer, input.id, {
-            channelId: ctx.channelId,
-        });
-        const updatedCustomer = patchEntity(customer, input);
-        await this.connection.getRepository(ctx, Customer).save(updatedCustomer, { reload: false });
-        await this.historyService.createHistoryEntryForCustomer({
-            customerId: customer.id,
-            ctx,
-            type: HistoryEntryType.CUSTOMER_DETAIL_UPDATED,
-            data: {
-                input,
-            },
-        });
-        return assertFound(this.findOne(ctx, customer.id));
-    }
-
     /**
      * For guest checkouts, we assume that a matching email address is the same customer.
      */

+ 2 - 1
packages/core/src/service/services/history.service.ts

@@ -1,4 +1,5 @@
 import { Injectable } from '@nestjs/common';
+import { UpdateCustomerInput as UpdateCustomerShopInput } from '@vendure/common/lib/generated-shop-types';
 import {
     HistoryEntryListOptions,
     HistoryEntryType,
@@ -29,7 +30,7 @@ export type CustomerHistoryEntryData = {
         strategy: string;
     };
     [HistoryEntryType.CUSTOMER_DETAIL_UPDATED]: {
-        input: UpdateCustomerInput;
+        input: UpdateCustomerInput | UpdateCustomerShopInput;
     };
     [HistoryEntryType.CUSTOMER_ADDRESS_CREATED]: {
         address: string;

+ 97 - 60
packages/core/src/service/services/order.service.ts

@@ -8,8 +8,10 @@ import {
     UpdateOrderItemsResult,
 } from '@vendure/common/lib/generated-shop-types';
 import {
+    AddFulfillmentToOrderResult,
     AddNoteToOrderInput,
     CancelOrderInput,
+    CancelOrderResult,
     CreateAddressInput,
     DeletionResponse,
     DeletionResult,
@@ -18,6 +20,8 @@ import {
     OrderLineInput,
     OrderProcessState,
     RefundOrderInput,
+    RefundOrderResult,
+    SettlePaymentResult,
     SettleRefundInput,
     ShippingMethodQuote,
     UpdateOrderNoteInput,
@@ -34,6 +38,19 @@ import {
     InternalServerError,
     UserInputError,
 } from '../../common/error/errors';
+import {
+    AlreadyRefundedError,
+    CancelActiveOrderError,
+    EmptyOrderLineSelectionError,
+    ItemsAlreadyFulfilledError,
+    MultipleOrderError,
+    NothingToRefundError,
+    PaymentOrderMismatchError,
+    PaymentStateTransitionError,
+    QuantityTooGreatError,
+    RefundOrderStateError,
+    SettlePaymentError,
+} from '../../common/error/generated-graphql-admin-errors';
 import {
     NegativeQuantityError,
     OrderLimitError,
@@ -607,7 +624,10 @@ export class OrderService {
         return order;
     }
 
-    async settlePayment(ctx: RequestContext, paymentId: ID): Promise<Payment> {
+    async settlePayment(
+        ctx: RequestContext,
+        paymentId: ID,
+    ): Promise<ErrorResultUnion<SettlePaymentResult, Payment>> {
         const payment = await this.connection.getEntityOrThrow(ctx, Payment, paymentId, {
             relations: ['order'],
         });
@@ -619,46 +639,56 @@ export class OrderService {
         if (settlePaymentResult.success) {
             const fromState = payment.state;
             const toState = 'Settled';
-            await this.paymentStateMachine.transition(ctx, payment.order, payment, toState);
+            try {
+                await this.paymentStateMachine.transition(ctx, payment.order, payment, toState);
+            } catch (e) {
+                const transitionError = ctx.translate(e.message, { fromState, toState });
+                return new PaymentStateTransitionError(transitionError, fromState, toState);
+            }
             payment.metadata = { ...payment.metadata, ...settlePaymentResult.metadata };
             await this.connection.getRepository(ctx, Payment).save(payment, { reload: false });
             this.eventBus.publish(
                 new PaymentStateTransitionEvent(fromState, toState, ctx, payment, payment.order),
             );
             if (payment.amount === payment.order.total) {
-                await this.transitionToState(ctx, payment.order.id, 'PaymentSettled');
+                const orderTransitionResult = await this.transitionToState(
+                    ctx,
+                    payment.order.id,
+                    'PaymentSettled',
+                );
+                if (isGraphQlErrorResult(orderTransitionResult)) {
+                    return orderTransitionResult;
+                }
             }
+        } else {
+            return new SettlePaymentError(settlePaymentResult.errorMessage || '');
         }
         return payment;
     }
 
-    async createFulfillment(ctx: RequestContext, input: FulfillOrderInput) {
+    async createFulfillment(
+        ctx: RequestContext,
+        input: FulfillOrderInput,
+    ): Promise<ErrorResultUnion<AddFulfillmentToOrderResult, Fulfillment>> {
         if (
             !input.lines ||
             input.lines.length === 0 ||
             input.lines.reduce((total, line) => total + line.quantity, 0) === 0
         ) {
-            throw new UserInputError('error.create-fulfillment-nothing-to-fulfill');
+            return new EmptyOrderLineSelectionError();
         }
-        const { items, orders } = await this.getOrdersAndItemsFromLines(
-            ctx,
-            input.lines,
-            i => !i.fulfillment,
-            'error.create-fulfillment-items-already-fulfilled',
-        );
-
-        for (const order of orders) {
-            if (order.state !== 'PaymentSettled' && order.state !== 'PartiallyDelivered') {
-                throw new IllegalOperationError('error.create-fulfillment-orders-must-be-settled');
-            }
+        const ordersAndItems = await this.getOrdersAndItemsFromLines(ctx, input.lines, i => !i.fulfillment);
+        if (!ordersAndItems) {
+            return new ItemsAlreadyFulfilledError();
         }
+
         const fulfillment = await this.fulfillmentService.create(ctx, {
             trackingCode: input.trackingCode,
             method: input.method,
-            orderItems: items,
+            orderItems: ordersAndItems.items,
         });
 
-        for (const order of orders) {
+        for (const order of ordersAndItems.orders) {
             await this.historyService.createHistoryEntryForOrder({
                 ctx,
                 orderId: order.id,
@@ -691,20 +721,33 @@ export class OrderService {
         const items = lines.reduce((acc, l) => [...acc, ...l.items], [] as OrderItem[]);
         return unique(items.map(i => i.fulfillment).filter(notNullOrUndefined), 'id');
     }
-    async cancelOrder(ctx: RequestContext, input: CancelOrderInput): Promise<Order> {
+
+    async cancelOrder(
+        ctx: RequestContext,
+        input: CancelOrderInput,
+    ): Promise<ErrorResultUnion<CancelOrderResult, Order>> {
         let allOrderItemsCancelled = false;
-        if (input.lines != null) {
-            allOrderItemsCancelled = await this.cancelOrderByOrderLines(ctx, input, input.lines);
+        const cancelResult =
+            input.lines != null
+                ? await this.cancelOrderByOrderLines(ctx, input, input.lines)
+                : await this.cancelOrderById(ctx, input);
+
+        if (isGraphQlErrorResult(cancelResult)) {
+            return cancelResult;
         } else {
-            allOrderItemsCancelled = await this.cancelOrderById(ctx, input);
+            allOrderItemsCancelled = cancelResult;
         }
+
         if (allOrderItemsCancelled) {
-            await this.transitionToState(ctx, input.orderId, 'Cancelled');
+            const transitionResult = await this.transitionToState(ctx, input.orderId, 'Cancelled');
+            if (isGraphQlErrorResult(transitionResult)) {
+                return transitionResult;
+            }
         }
         return assertFound(this.findOne(ctx, input.orderId));
     }
 
-    private async cancelOrderById(ctx: RequestContext, input: CancelOrderInput): Promise<boolean> {
+    private async cancelOrderById(ctx: RequestContext, input: CancelOrderInput) {
         const order = await this.getOrderOrThrow(ctx, input.orderId);
         if (order.state === 'AddingItems' || order.state === 'ArrangingPayment') {
             return true;
@@ -721,27 +764,24 @@ export class OrderService {
         ctx: RequestContext,
         input: CancelOrderInput,
         lines: OrderLineInput[],
-    ): Promise<boolean> {
+    ) {
         if (lines.length === 0 || lines.reduce((total, line) => total + line.quantity, 0) === 0) {
-            throw new UserInputError('error.cancel-order-lines-nothing-to-cancel');
+            return new EmptyOrderLineSelectionError();
         }
-        const { items, orders } = await this.getOrdersAndItemsFromLines(
-            ctx,
-            lines,
-            i => !i.cancelled,
-            'error.cancel-order-lines-quantity-too-high',
-        );
-        if (1 < orders.length) {
-            throw new IllegalOperationError('error.order-lines-must-belong-to-same-order');
+        const ordersAndItems = await this.getOrdersAndItemsFromLines(ctx, lines, i => !i.cancelled);
+        if (!ordersAndItems) {
+            return new QuantityTooGreatError();
+        }
+        if (1 < ordersAndItems.orders.length) {
+            return new MultipleOrderError();
         }
+        const { orders, items } = ordersAndItems;
         const order = orders[0];
         if (!idsAreEqual(order.id, input.orderId)) {
-            throw new IllegalOperationError('error.order-lines-must-belong-to-same-order');
+            return new MultipleOrderError();
         }
         if (order.state === 'AddingItems' || order.state === 'ArrangingPayment') {
-            throw new IllegalOperationError('error.cancel-order-lines-invalid-order-state', {
-                state: order.state,
-            });
+            return new CancelActiveOrderError(order.state);
         }
 
         // Perform the cancellation
@@ -749,12 +789,9 @@ export class OrderService {
         items.forEach(i => (i.cancelled = true));
         await this.connection.getRepository(ctx, OrderItem).save(items, { reload: false });
 
-        const orderWithItems = await this.connection.getRepository(ctx, Order).findOne(order.id, {
+        const orderWithItems = await this.connection.getEntityOrThrow(ctx, Order, order.id, {
             relations: ['lines', 'lines.items'],
         });
-        if (!orderWithItems) {
-            throw new InternalServerError('error.could-not-find-order');
-        }
         await this.historyService.createHistoryEntryForOrder({
             ctx,
             orderId: order.id,
@@ -767,29 +804,31 @@ export class OrderService {
         return orderItemsAreAllCancelled(orderWithItems);
     }
 
-    async refundOrder(ctx: RequestContext, input: RefundOrderInput): Promise<Refund> {
+    async refundOrder(
+        ctx: RequestContext,
+        input: RefundOrderInput,
+    ): Promise<ErrorResultUnion<RefundOrderResult, Refund>> {
         if (
             (!input.lines ||
                 input.lines.length === 0 ||
                 input.lines.reduce((total, line) => total + line.quantity, 0) === 0) &&
             input.shipping === 0
         ) {
-            throw new UserInputError('error.refund-order-lines-nothing-to-refund');
+            return new NothingToRefundError();
         }
-        const { items, orders } = await this.getOrdersAndItemsFromLines(
-            ctx,
-            input.lines,
-            i => !i.cancelled,
-            'error.refund-order-lines-quantity-too-high',
-        );
+        const ordersAndItems = await this.getOrdersAndItemsFromLines(ctx, input.lines, i => !i.cancelled);
+        if (!ordersAndItems) {
+            return new QuantityTooGreatError();
+        }
+        const { orders, items } = ordersAndItems;
         if (1 < orders.length) {
-            throw new IllegalOperationError('error.order-lines-must-belong-to-same-order');
+            return new MultipleOrderError();
         }
         const payment = await this.connection.getEntityOrThrow(ctx, Payment, input.paymentId, {
             relations: ['order'],
         });
         if (orders && orders.length && !idsAreEqual(payment.order.id, orders[0].id)) {
-            throw new IllegalOperationError('error.refund-order-payment-lines-mismatch');
+            return new PaymentOrderMismatchError();
         }
         const order = payment.order;
         if (
@@ -797,12 +836,11 @@ export class OrderService {
             order.state === 'ArrangingPayment' ||
             order.state === 'PaymentAuthorized'
         ) {
-            throw new IllegalOperationError('error.refund-order-lines-invalid-order-state', {
-                state: order.state,
-            });
+            return new RefundOrderStateError(order.state);
         }
-        if (items.some(i => !!i.refundId)) {
-            throw new IllegalOperationError('error.refund-order-item-already-refunded');
+        const alreadyRefunded = items.find(i => !!i.refundId);
+        if (alreadyRefunded) {
+            return new AlreadyRefundedError(alreadyRefunded.refundId as string);
         }
 
         return await this.paymentMethodService.createRefund(ctx, input, order, items, payment);
@@ -1029,8 +1067,7 @@ export class OrderService {
         ctx: RequestContext,
         orderLinesInput: OrderLineInput[],
         itemMatcher: (i: OrderItem) => boolean,
-        noMatchesError: string,
-    ): Promise<{ orders: Order[]; items: OrderItem[] }> {
+    ): Promise<{ orders: Order[]; items: OrderItem[] } | false> {
         const orders = new Map<ID, Order>();
         const items = new Map<ID, OrderItem>();
 
@@ -1055,7 +1092,7 @@ export class OrderService {
             }
             const matchingItems = line.items.sort((a, b) => (a.id < b.id ? -1 : 1)).filter(itemMatcher);
             if (matchingItems.length < inputLine.quantity) {
-                throw new IllegalOperationError(noMatchesError);
+                return false;
             }
             matchingItems.slice(0, inputLine.quantity).forEach(item => {
                 items.set(item.id, item);

+ 7 - 2
packages/core/src/service/services/payment-method.service.ts

@@ -11,6 +11,7 @@ import { assertNever } from '@vendure/common/lib/shared-utils';
 
 import { RequestContext } from '../../api/common/request-context';
 import { UserInputError } from '../../common/error/errors';
+import { RefundStateTransitionError } from '../../common/error/generated-graphql-admin-errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { ConfigService } from '../../config/config.service';
 import { PaymentMethodHandler } from '../../config/payment-method/payment-method-handler';
@@ -108,7 +109,7 @@ export class PaymentMethodService {
         order: Order,
         items: OrderItem[],
         payment: Payment,
-    ): Promise<Refund> {
+    ): Promise<Refund | RefundStateTransitionError> {
         const { paymentMethod, handler } = await this.getMethodAndHandler(ctx, payment.method);
         const itemAmount = items.reduce((sum, item) => sum + item.unitPriceWithTax, 0);
         const refundAmount = itemAmount + input.shipping + input.adjustment;
@@ -138,7 +139,11 @@ export class PaymentMethodService {
         refund = await this.connection.getRepository(ctx, Refund).save(refund);
         if (createRefundResult) {
             const fromState = refund.state;
-            await this.refundStateMachine.transition(ctx, order, refund, createRefundResult.state);
+            try {
+                await this.refundStateMachine.transition(ctx, order, refund, createRefundResult.state);
+            } catch (e) {
+                return new RefundStateTransitionError(e.message, fromState, createRefundResult.state);
+            }
             await this.connection.getRepository(ctx, Refund).save(refund, { reload: false });
             this.eventBus.publish(
                 new RefundStateTransitionEvent(fromState, createRefundResult.state, ctx, refund, order),

+ 5 - 5
packages/core/src/service/services/product.service.ts

@@ -5,6 +5,7 @@ import {
     DeletionResponse,
     DeletionResult,
     Permission,
+    RemoveOptionGroupFromProductResult,
     RemoveProductsFromChannelInput,
     UpdateProductInput,
 } from '@vendure/common/lib/generated-types';
@@ -12,7 +13,9 @@ import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { FindOptionsUtils } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
+import { ErrorResultUnion } from '../../common/error/error-result';
 import { EntityNotFoundError, ForbiddenError, UserInputError } from '../../common/error/errors';
+import { ProductOptionInUseError } from '../../common/error/generated-graphql-admin-errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { assertFound, idsAreEqual } from '../../common/utils';
@@ -282,17 +285,14 @@ export class ProductService {
         ctx: RequestContext,
         productId: ID,
         optionGroupId: ID,
-    ): Promise<Translated<Product>> {
+    ): Promise<ErrorResultUnion<RemoveOptionGroupFromProductResult, Translated<Product>>> {
         const product = await this.getProductWithOptionGroups(ctx, productId);
         const optionGroup = product.optionGroups.find(g => idsAreEqual(g.id, optionGroupId));
         if (!optionGroup) {
             throw new EntityNotFoundError('ProductOptionGroup', optionGroupId);
         }
         if (product.variants.length) {
-            throw new UserInputError('error.cannot-remove-option-group-due-to-variants', {
-                code: optionGroup.code,
-                count: product.variants.length,
-            });
+            return new ProductOptionInUseError(optionGroup.code, product.variants.length);
         }
         product.optionGroups = product.optionGroups.filter(g => g.id !== optionGroupId);
 

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

@@ -7,17 +7,20 @@ import {
     ConfigurableOperationDefinition,
     ConfigurableOperationInput,
     CreatePromotionInput,
+    CreatePromotionResult,
     DeletionResponse,
     DeletionResult,
     UpdatePromotionInput,
+    UpdatePromotionResult,
 } from '@vendure/common/lib/generated-types';
 import { omit } from '@vendure/common/lib/omit';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../api/common/request-context';
-import { JustErrorResults } from '../../common/error/error-result';
+import { ErrorResultUnion, JustErrorResults } from '../../common/error/error-result';
 import { UserInputError } from '../../common/error/errors';
+import { MissingConditionsError } from '../../common/error/generated-graphql-admin-errors';
 import {
     CouponCodeExpiredError,
     CouponCodeInvalidError,
@@ -97,7 +100,10 @@ export class PromotionService {
         return this.activePromotions;
     }
 
-    async createPromotion(ctx: RequestContext, input: CreatePromotionInput): Promise<Promotion> {
+    async createPromotion(
+        ctx: RequestContext,
+        input: CreatePromotionInput,
+    ): Promise<ErrorResultUnion<CreatePromotionResult, Promotion>> {
         const promotion = new Promotion({
             name: input.name,
             enabled: input.enabled,
@@ -109,14 +115,19 @@ export class PromotionService {
             actions: input.actions.map(a => this.parseOperationArgs('action', a)),
             priorityScore: this.calculatePriorityScore(input),
         });
-        this.validatePromotionConditions(promotion);
+        if (promotion.conditions.length === 0 && !promotion.couponCode) {
+            return new MissingConditionsError();
+        }
         this.channelService.assignToCurrentChannel(promotion, ctx);
         const newPromotion = await this.connection.getRepository(ctx, Promotion).save(promotion);
         await this.updatePromotions();
         return assertFound(this.findOne(ctx, newPromotion.id));
     }
 
-    async updatePromotion(ctx: RequestContext, input: UpdatePromotionInput): Promise<Promotion> {
+    async updatePromotion(
+        ctx: RequestContext,
+        input: UpdatePromotionInput,
+    ): Promise<ErrorResultUnion<UpdatePromotionResult, Promotion>> {
         const promotion = await this.connection.getEntityOrThrow(ctx, Promotion, input.id, {
             channelId: ctx.channelId,
         });
@@ -127,7 +138,9 @@ export class PromotionService {
         if (input.actions) {
             updatedPromotion.actions = input.actions.map(a => this.parseOperationArgs('action', a));
         }
-        this.validatePromotionConditions(updatedPromotion);
+        if (promotion.conditions.length === 0 && !promotion.couponCode) {
+            return new MissingConditionsError();
+        }
         promotion.priorityScore = this.calculatePriorityScore(input);
         await this.connection.getRepository(ctx, Promotion).save(updatedPromotion, { reload: false });
         await this.updatePromotions();
@@ -246,13 +259,4 @@ export class PromotionService {
             where: { enabled: true },
         });
     }
-
-    /**
-     * Ensure the Promotion has at least one condition or a couponCode specified.
-     */
-    private validatePromotionConditions(promotion: Promotion) {
-        if (promotion.conditions.length === 0 && !promotion.couponCode) {
-            throw new UserInputError('error.promotion-must-have-conditions-or-coupon-code');
-        }
-    }
 }

+ 1 - 1
packages/core/src/service/services/tax-category.service.ts

@@ -50,7 +50,7 @@ export class TaxCategoryService {
             .count({ where: { category: id } });
 
         if (0 < dependentRates) {
-            const message = ctx.translate('error.cannot-remove-tax-category-due-to-tax-rates', {
+            const message = ctx.translate('message.cannot-remove-tax-category-due-to-tax-rates', {
                 name: taxCategory.name,
                 count: dependentRates,
             });

+ 20 - 0
packages/dev-server/dev-config.ts

@@ -9,13 +9,32 @@ import {
     examplePaymentHandler,
     LanguageCode,
     LogLevel,
+    PluginCommonModule,
+    ProductService,
     VendureConfig,
+    VendurePlugin,
 } from '@vendure/core';
 import { ElasticsearchPlugin } from '@vendure/elasticsearch-plugin';
 import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
 import path from 'path';
 import { ConnectionOptions } from 'typeorm';
 
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    providers: [
+        {
+            provide: ProductService,
+            useClass: class MyProdService {
+                findOne() {
+                    console.log('YOLO');
+                    return {};
+                }
+            },
+        },
+    ],
+})
+class MyPlugin {}
+
 /**
  * Config settings used during development
  */
@@ -58,6 +77,7 @@ export const devConfig: VendureConfig = {
         importAssetsDir: path.join(__dirname, 'import-assets'),
     },
     plugins: [
+        MyPlugin,
         AssetServerPlugin.init({
             route: 'assets',
             assetUploadDir: path.join(__dirname, 'assets'),

+ 231 - 22
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -16,6 +16,11 @@ export type Scalars = {
     Upload: any;
 };
 
+export type AddFulfillmentToOrderResult =
+    | Fulfillment
+    | EmptyOrderLineSelectionError
+    | ItemsAlreadyFulfilledError;
+
 export type AddNoteToCustomerInput = {
     id: Scalars['ID'];
     note: Scalars['String'];
@@ -102,6 +107,13 @@ export type AdministratorSortParameter = {
     emailAddress?: Maybe<SortOrder>;
 };
 
+/** Returned if an attempting to refund an OrderItem which has already been refunded */
+export type AlreadyRefundedError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    refundId: Scalars['ID'];
+};
+
 export type Asset = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -178,6 +190,8 @@ export type AuthenticationMethod = Node & {
     strategy: Scalars['String'];
 };
 
+export type AuthenticationResult = CurrentUser | InvalidCredentialsError;
+
 export type BooleanCustomFieldConfig = CustomField & {
     name: Scalars['String'];
     type: Scalars['String'];
@@ -192,6 +206,13 @@ export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
 };
 
+/** Returned if an attempting to cancel lines from an Order which is still active */
+export type CancelActiveOrderError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    orderState: Scalars['String'];
+};
+
 export type Cancellation = Node &
     StockMovement & {
         id: Scalars['ID'];
@@ -211,6 +232,14 @@ export type CancelOrderInput = {
     reason?: Maybe<Scalars['String']>;
 };
 
+export type CancelOrderResult =
+    | Order
+    | EmptyOrderLineSelectionError
+    | QuantityTooGreatError
+    | MultipleOrderError
+    | CancelActiveOrderError
+    | OrderStateTransitionError;
+
 export type Channel = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -224,6 +253,17 @@ export type Channel = Node & {
     pricesIncludeTax: Scalars['Boolean'];
 };
 
+/**
+ * Returned when the default LanguageCode of a Channel is no longer found in the `availableLanguages`
+ * of the GlobalSettings
+ */
+export type ChannelDefaultLanguageError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    language: Scalars['String'];
+    channelCode: Scalars['String'];
+};
+
 export type Collection = Node & {
     isPrivate: Scalars['Boolean'];
     id: Scalars['ID'];
@@ -436,6 +476,8 @@ export type CreateChannelInput = {
     defaultShippingZoneId: Scalars['ID'];
 };
 
+export type CreateChannelResult = Channel | LanguageNotAvailableError;
+
 export type CreateCollectionInput = {
     isPrivate?: Maybe<Scalars['Boolean']>;
     featuredAssetId?: Maybe<Scalars['ID']>;
@@ -474,6 +516,8 @@ export type CreateCustomerInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CreateCustomerResult = Customer | EmailAddressConflictError;
+
 export type CreateFacetInput = {
     code: Scalars['String'];
     isPrivate: Scalars['Boolean'];
@@ -553,6 +597,8 @@ export type CreatePromotionInput = {
     actions: Array<ConfigurableOperationInput>;
 };
 
+export type CreatePromotionResult = Promotion | MissingConditionsError;
+
 export type CreateRoleInput = {
     code: Scalars['String'];
     description: Scalars['String'];
@@ -1093,10 +1139,42 @@ export enum DeletionResult {
     NOT_DELETED = 'NOT_DELETED',
 }
 
+/** Retured when attemting to create a Customer with an email address already registered to an existing User. */
+export type EmailAddressConflictError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned if no OrderLines have been specified for the operation */
+export type EmptyOrderLineSelectionError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export enum ErrorCode {
     UNKNOWN_ERROR = 'UNKNOWN_ERROR',
     MIME_TYPE_ERROR = 'MIME_TYPE_ERROR',
+    INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS_ERROR',
+    NATIVE_AUTH_STRATEGY_ERROR = 'NATIVE_AUTH_STRATEGY_ERROR',
+    LANGUAGE_NOT_AVAILABLE_ERROR = 'LANGUAGE_NOT_AVAILABLE_ERROR',
+    EMAIL_ADDRESS_CONFLICT_ERROR = 'EMAIL_ADDRESS_CONFLICT_ERROR',
+    CHANNEL_DEFAULT_LANGUAGE_ERROR = 'CHANNEL_DEFAULT_LANGUAGE_ERROR',
+    SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
+    PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
     ORDER_STATE_TRANSITION_ERROR = 'ORDER_STATE_TRANSITION_ERROR',
+    EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
+    ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
+    QUANTITY_TOO_GREAT_ERROR = 'QUANTITY_TOO_GREAT_ERROR',
+    MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
+    CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
+    NOTHING_TO_REFUND_ERROR = 'NOTHING_TO_REFUND_ERROR',
+    PAYMENT_ORDER_MISMATCH_ERROR = 'PAYMENT_ORDER_MISMATCH_ERROR',
+    REFUND_ORDER_STATE_ERROR = 'REFUND_ORDER_STATE_ERROR',
+    ALREADY_REFUNDED_ERROR = 'ALREADY_REFUNDED_ERROR',
+    REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
+    FULFILLMENT_STATE_TRANSITION_ERROR = 'FULFILLMENT_STATE_TRANSITION_ERROR',
+    PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
+    MISSING_CONDITIONS_ERROR = 'MISSING_CONDITIONS_ERROR',
 }
 
 export type ErrorResult = {
@@ -1221,6 +1299,15 @@ export type Fulfillment = Node & {
     trackingCode?: Maybe<Scalars['String']>;
 };
 
+/** Returned when there is an error in transitioning the Fulfillment state */
+export type FulfillmentStateTransitionError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    transitionError: Scalars['String'];
+    fromState: Scalars['String'];
+    toState: Scalars['String'];
+};
+
 export type FulfillOrderInput = {
     lines: Array<OrderLineInput>;
     method: Scalars['String'];
@@ -1317,6 +1404,18 @@ export type IntCustomFieldConfig = CustomField & {
     step?: Maybe<Scalars['Int']>;
 };
 
+/** Returned if the user authentication credentials are not valid */
+export type InvalidCredentialsError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned if the specified items are already part of a Fulfillment */
+export type ItemsAlreadyFulfilledError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type Job = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -1710,6 +1809,12 @@ export enum LanguageCode {
     zu = 'zu',
 }
 
+export type LanguageNotAvailableError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    languageCode: Scalars['String'];
+};
+
 export type LocaleStringCustomFieldConfig = CustomField & {
     name: Scalars['String'];
     type: Scalars['String'];
@@ -1732,10 +1837,6 @@ export enum LogicalOperator {
     OR = 'OR',
 }
 
-export type LoginResult = {
-    user: CurrentUser;
-};
-
 export type MimeTypeError = ErrorResult & {
     code: ErrorCode;
     message: Scalars['String'];
@@ -1743,12 +1844,24 @@ export type MimeTypeError = ErrorResult & {
     mimeType: Scalars['String'];
 };
 
+/** Returned if a PromotionCondition has neither a couponCode nor any conditions set */
+export type MissingConditionsError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type MoveCollectionInput = {
     collectionId: Scalars['ID'];
     parentId: Scalars['ID'];
     index: Scalars['Int'];
 };
 
+/** Returned if an operation has specified OrderLines from multiple Orders */
+export type MultipleOrderError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type Mutation = {
     /** Create a new Administrator */
     createAdministrator: Administrator;
@@ -1770,14 +1883,14 @@ export type Mutation = {
      * Authenticates the user using the native authentication strategy. This mutation
      * is an alias for `authenticate({ native: { ... }})`
      */
-    login: LoginResult;
+    login: NativeAuthenticationResult;
     /** Authenticates the user using a named authentication strategy */
-    authenticate: LoginResult;
-    logout: Scalars['Boolean'];
+    authenticate: AuthenticationResult;
+    logout: Success;
     /** Create a new Channel */
-    createChannel: Channel;
+    createChannel: CreateChannelResult;
     /** Update an existing Channel */
-    updateChannel: Channel;
+    updateChannel: UpdateChannelResult;
     /** Delete a Channel */
     deleteChannel: DeletionResponse;
     /** Create a new Collection */
@@ -1805,9 +1918,9 @@ export type Mutation = {
     /** Remove Customers from a CustomerGroup */
     removeCustomersFromGroup: CustomerGroup;
     /** Create a new Customer. If a password is provided, a new User will also be created an linked to the Customer. */
-    createCustomer: Customer;
+    createCustomer: CreateCustomerResult;
     /** Update an existing Customer */
-    updateCustomer: Customer;
+    updateCustomer: UpdateCustomerResult;
     /** Delete a Customer */
     deleteCustomer: DeletionResponse;
     /** Create a new Address and associate it with the Customer specified by customerId */
@@ -1831,20 +1944,20 @@ export type Mutation = {
     updateFacetValues: Array<FacetValue>;
     /** Delete one or more FacetValues */
     deleteFacetValues: Array<DeletionResponse>;
-    updateGlobalSettings: GlobalSettings;
+    updateGlobalSettings: UpdateGlobalSettingsResult;
     importProducts?: Maybe<ImportInfo>;
     /** Remove all settled jobs in the given queues olfer than the given date. Returns the number of jobs deleted. */
     removeSettledJobs: Scalars['Int'];
-    settlePayment: Payment;
-    fulfillOrder: Fulfillment;
-    cancelOrder: Order;
-    refundOrder: Refund;
-    settleRefund: Refund;
+    settlePayment: SettlePaymentResult;
+    addFulfillmentToOrder: AddFulfillmentToOrderResult;
+    cancelOrder: CancelOrderResult;
+    refundOrder: RefundOrderResult;
+    settleRefund: SettleRefundResult;
     addNoteToOrder: Order;
     updateOrderNote: HistoryEntry;
     deleteOrderNote: DeletionResponse;
     transitionOrderToState?: Maybe<TransitionOrderToStateResult>;
-    transitionFulfillmentToState: Fulfillment;
+    transitionFulfillmentToState: TransitionFulfillmentToStateResult;
     setOrderCustomFields?: Maybe<Order>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
@@ -1866,7 +1979,7 @@ export type Mutation = {
     /** Add an OptionGroup to a Product */
     addOptionGroupToProduct: Product;
     /** Remove an OptionGroup from a Product */
-    removeOptionGroupFromProduct: Product;
+    removeOptionGroupFromProduct: RemoveOptionGroupFromProductResult;
     /** Create a set of ProductVariants based on the OptionGroups assigned to the given Product */
     createProductVariants: Array<Maybe<ProductVariant>>;
     /** Update existing ProductVariants */
@@ -1877,8 +1990,8 @@ export type Mutation = {
     assignProductsToChannel: Array<Product>;
     /** Removes Products from the specified Channel */
     removeProductsFromChannel: Array<Product>;
-    createPromotion: Promotion;
-    updatePromotion: Promotion;
+    createPromotion: CreatePromotionResult;
+    updatePromotion: UpdatePromotionResult;
     deletePromotion: DeletionResponse;
     /** Create a new Role */
     createRole: Role;
@@ -2105,7 +2218,7 @@ export type MutationSettlePaymentArgs = {
     id: Scalars['ID'];
 };
 
-export type MutationFulfillOrderArgs = {
+export type MutationAddFulfillmentToOrderArgs = {
     input: FulfillOrderInput;
 };
 
@@ -2291,15 +2404,29 @@ export type MutationRemoveMembersFromZoneArgs = {
     memberIds: Array<Scalars['ID']>;
 };
 
+export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
+
 export type NativeAuthInput = {
     username: Scalars['String'];
     password: Scalars['String'];
 };
 
+/** Retured when attempting an operation that relies on the NativeAuthStrategy, if that strategy is not configured. */
+export type NativeAuthStrategyError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type Node = {
     id: Scalars['ID'];
 };
 
+/** Returned if an attempting to refund an Order but neither items nor shipping refund was specified */
+export type NothingToRefundError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type NumberOperators = {
     eq?: Maybe<Scalars['Float']>;
     lt?: Maybe<Scalars['Float']>;
@@ -2509,6 +2636,21 @@ export type PaymentMethodSortParameter = {
     code?: Maybe<SortOrder>;
 };
 
+/** Returned if an attempting to refund a Payment against OrderLines from a different Order */
+export type PaymentOrderMismatchError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned when there is an error in transitioning the Payment state */
+export type PaymentStateTransitionError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    transitionError: Scalars['String'];
+    fromState: Scalars['String'];
+    toState: Scalars['String'];
+};
+
 /**
  * "
  * @description
@@ -2640,6 +2782,13 @@ export type ProductOptionGroupTranslationInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type ProductOptionInUseError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    optionGroupCode: Scalars['String'];
+    productVariantCount: Scalars['Int'];
+};
+
 export type ProductOptionTranslation = {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -2816,6 +2965,12 @@ export type PromotionSortParameter = {
     name?: Maybe<SortOrder>;
 };
 
+/** Returned if the specified quantity of an OrderLine is greater than the number of items in that line */
+export type QuantityTooGreatError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+};
+
 export type Query = {
     administrators: AdministratorList;
     administrator?: Maybe<Administrator>;
@@ -3063,6 +3218,35 @@ export type RefundOrderInput = {
     reason?: Maybe<Scalars['String']>;
 };
 
+export type RefundOrderResult =
+    | Refund
+    | QuantityTooGreatError
+    | NothingToRefundError
+    | OrderStateTransitionError
+    | MultipleOrderError
+    | PaymentOrderMismatchError
+    | RefundOrderStateError
+    | AlreadyRefundedError
+    | RefundStateTransitionError;
+
+/** Returned if an attempting to refund an Order which is not in the expected state */
+export type RefundOrderStateError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    orderState: Scalars['String'];
+};
+
+/** Returned when there is an error in transitioning the Refund state */
+export type RefundStateTransitionError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    transitionError: Scalars['String'];
+    fromState: Scalars['String'];
+    toState: Scalars['String'];
+};
+
+export type RemoveOptionGroupFromProductResult = Product | ProductOptionInUseError;
+
 export type RemoveProductsFromChannelInput = {
     productIds: Array<Scalars['ID']>;
     channelId: Scalars['ID'];
@@ -3197,11 +3381,26 @@ export type ServerConfig = {
     customFieldConfig: CustomFields;
 };
 
+/** Returned if the Payment settlement fails */
+export type SettlePaymentError = ErrorResult & {
+    code: ErrorCode;
+    message: Scalars['String'];
+    paymentErrorMessage: Scalars['String'];
+};
+
+export type SettlePaymentResult =
+    | Payment
+    | SettlePaymentError
+    | PaymentStateTransitionError
+    | OrderStateTransitionError;
+
 export type SettleRefundInput = {
     id: Scalars['ID'];
     transactionId: Scalars['String'];
 };
 
+export type SettleRefundResult = Refund | RefundStateTransitionError;
+
 export type ShippingMethod = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -3401,6 +3600,8 @@ export type TestShippingMethodResult = {
     quote?: Maybe<TestShippingMethodQuote>;
 };
 
+export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 export type UpdateAddressInput = {
@@ -3445,6 +3646,8 @@ export type UpdateChannelInput = {
     defaultShippingZoneId?: Maybe<Scalars['ID']>;
 };
 
+export type UpdateChannelResult = Channel | LanguageNotAvailableError;
+
 export type UpdateCollectionInput = {
     id: Scalars['ID'];
     isPrivate?: Maybe<Scalars['Boolean']>;
@@ -3492,6 +3695,8 @@ export type UpdateCustomerNoteInput = {
     note: Scalars['String'];
 };
 
+export type UpdateCustomerResult = Customer | EmailAddressConflictError;
+
 export type UpdateFacetInput = {
     id: Scalars['ID'];
     isPrivate?: Maybe<Scalars['Boolean']>;
@@ -3513,6 +3718,8 @@ export type UpdateGlobalSettingsInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type UpdateGlobalSettingsResult = GlobalSettings | ChannelDefaultLanguageError;
+
 export type UpdateOrderInput = {
     id: Scalars['ID'];
     customFields?: Maybe<Scalars['JSON']>;
@@ -3582,6 +3789,8 @@ export type UpdatePromotionInput = {
     actions?: Maybe<Array<ConfigurableOperationInput>>;
 };
 
+export type UpdatePromotionResult = Promotion | MissingConditionsError;
+
 export type UpdateRoleInput = {
     id: Scalars['ID'];
     code?: Maybe<Scalars['String']>;

+ 5 - 5
packages/elasticsearch-plugin/src/elasticsearch.service.ts

@@ -312,21 +312,21 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
 
     private getSearchResultAssets(
         source: ProductIndexItem | VariantIndexItem,
-    ): { productAsset: SearchResultAsset | null; productVariantAsset: SearchResultAsset | null } {
-        const productAsset: SearchResultAsset | null = source.productAssetId
+    ): { productAsset: SearchResultAsset | undefined; productVariantAsset: SearchResultAsset | undefined } {
+        const productAsset: SearchResultAsset | undefined = source.productAssetId
             ? {
                   id: source.productAssetId.toString(),
                   preview: source.productPreview,
                   focalPoint: source.productPreviewFocalPoint,
               }
-            : null;
-        const productVariantAsset: SearchResultAsset | null = source.productVariantAssetId
+            : undefined;
+        const productVariantAsset: SearchResultAsset | undefined = source.productVariantAssetId
             ? {
                   id: source.productVariantAssetId.toString(),
                   preview: source.productVariantPreview,
                   focalPoint: source.productVariantPreviewFocalPoint,
               }
-            : null;
+            : undefined;
         return { productAsset, productVariantAsset };
     }
 

+ 8 - 8
packages/elasticsearch-plugin/src/indexer.controller.ts

@@ -637,13 +637,13 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             slug: v.product.slug,
             productId: v.product.id,
             productName: v.product.name,
-            productAssetId: productAsset ? productAsset.id : null,
+            productAssetId: productAsset ? productAsset.id : undefined,
             productPreview: productAsset ? productAsset.preview : '',
-            productPreviewFocalPoint: productAsset ? productAsset.focalPoint || null : null,
+            productPreviewFocalPoint: productAsset ? productAsset.focalPoint || undefined : undefined,
             productVariantName: v.name,
-            productVariantAssetId: variantAsset ? variantAsset.id : null,
+            productVariantAssetId: variantAsset ? variantAsset.id : undefined,
             productVariantPreview: variantAsset ? variantAsset.preview : '',
-            productVariantPreviewFocalPoint: productAsset ? productAsset.focalPoint || null : null,
+            productVariantPreviewFocalPoint: productAsset ? productAsset.focalPoint || undefined : undefined,
             price: v.price,
             priceWithTax: v.priceWithTax,
             currencyCode: v.currencyCode,
@@ -676,14 +676,14 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             slug: first.product.slug,
             productId: first.product.id,
             productName: first.product.name,
-            productAssetId: productAsset ? productAsset.id : null,
+            productAssetId: productAsset ? productAsset.id : undefined,
             productPreview: productAsset ? productAsset.preview : '',
-            productPreviewFocalPoint: productAsset ? productAsset.focalPoint || null : null,
+            productPreviewFocalPoint: productAsset ? productAsset.focalPoint || undefined : undefined,
             productVariantId: first.id,
             productVariantName: first.name,
-            productVariantAssetId: variantAsset ? variantAsset.id : null,
+            productVariantAssetId: variantAsset ? variantAsset.id : undefined,
             productVariantPreview: variantAsset ? variantAsset.preview : '',
-            productVariantPreviewFocalPoint: productAsset ? productAsset.focalPoint || null : null,
+            productVariantPreviewFocalPoint: productAsset ? productAsset.focalPoint || undefined : undefined,
             priceMin: Math.min(...prices),
             priceMax: Math.max(...prices),
             priceWithTaxMin: Math.min(...pricesWithTax),

+ 5 - 5
packages/elasticsearch-plugin/src/types.ts

@@ -31,12 +31,12 @@ export type PriceRangeBucket = {
 };
 
 export type IndexItemAssets = {
-    productAssetId: ID | null;
+    productAssetId: ID | undefined;
     productPreview: string;
-    productPreviewFocalPoint: Coordinate | null;
-    productVariantAssetId: ID | null;
+    productPreviewFocalPoint: Coordinate | undefined;
+    productVariantAssetId: ID | undefined;
     productVariantPreview: string;
-    productVariantPreviewFocalPoint: Coordinate | null;
+    productVariantPreviewFocalPoint: Coordinate | undefined;
 };
 
 export type VariantIndexItem = Omit<
@@ -214,7 +214,7 @@ export class DeleteAssetMessage extends WorkerMessage<UpdateAssetMessageData, bo
     static readonly pattern = 'DeleteAsset';
 }
 
-type Maybe<T> = T | null | undefined;
+type Maybe<T> = T | undefined;
 type CustomMappingDefinition<Args extends any[], T extends string, R> = {
     graphQlType: T;
     valueFn: (...args: Args) => R;

+ 8 - 3
packages/testing/src/data-population/mock-data.service.ts

@@ -23,8 +23,10 @@ export class MockDataService {
             const query1 = gql`
                 mutation CreateCustomer($input: CreateCustomerInput!, $password: String) {
                     createCustomer(input: $input, password: $password) {
-                        id
-                        emailAddress
+                        ... on Customer {
+                            id
+                            emailAddress
+                        }
                     }
                 }
             `;
@@ -41,7 +43,10 @@ export class MockDataService {
 
             const customer: { id: string; emailAddress: string } | void = await this.client
                 .query(query1, variables1)
-                .then((data: any) => data.createCustomer, err => this.log(err));
+                .then(
+                    (data: any) => data.createCustomer,
+                    err => this.log(err),
+                );
 
             if (customer) {
                 const query2 = gql`

+ 13 - 5
packages/testing/src/simple-graphql-client.ts

@@ -14,13 +14,17 @@ import { createUploadPostData } from './utils/create-upload-post-data';
 const LOGIN = gql`
     mutation($username: String!, $password: String!) {
         login(username: $username, password: $password) {
-            user {
+            ... on CurrentUser {
                 id
                 identifier
                 channels {
                     token
                 }
             }
+            ... on ErrorResult {
+                code
+                message
+            }
         }
     }
 `;
@@ -129,14 +133,16 @@ export class SimpleGraphQLClient {
             await this.query(
                 gql`
                     mutation {
-                        logout
+                        logout {
+                            success
+                        }
                     }
                 `,
             );
         }
         const result = await this.query(LOGIN, { username, password });
-        if (result.login.user.channels.length === 1) {
-            this.setChannelToken(result.login.user.channels[0].token);
+        if (result.login.channels?.length === 1) {
+            this.setChannelToken(result.login.channels[0].token);
         }
         return result.login;
     }
@@ -161,7 +167,9 @@ export class SimpleGraphQLClient {
         await this.query(
             gql`
                 mutation {
-                    logout
+                    logout {
+                        success
+                    }
                 }
             `,
         );

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
schema-admin.json


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
schema-shop.json


+ 15 - 11
scripts/codegen/plugins/graphql-errors-plugin.ts

@@ -20,6 +20,7 @@ import {
     UnionTypeDefinitionNode,
     visit,
     Visitor,
+    ListTypeNode,
 } from 'graphql';
 
 // This plugin generates classes for all GraphQL types which implement the `ErrorResult` interface.
@@ -32,11 +33,17 @@ export const ERROR_INTERFACE_NAME = 'ErrorResult';
 const empty = () => '';
 
 const errorsVisitor: Visitor<any> = {
-    NonNullType(node: NonNullTypeNode): string {
-        return node.type.kind === 'NamedType' ? node.type.name.value : '';
+    NonNullType(node: NonNullTypeNode): string | ListTypeNode {
+        return node.type.kind === 'NamedType'
+            ? node.type.name.value
+            : node.type.kind === 'ListType'
+            ? node.type
+            : '';
     },
     FieldDefinition(node: FieldDefinitionNode): string {
-        return `  ${node.name.value}: Scalars['${node.type}']`;
+        const scalarType = node.type.kind === 'ListType' ? node.type.type : node.type;
+        const listPart = node.type.kind === 'ListType' ? `[]` : ``;
+        return `  ${node.name.value}: Scalars['${scalarType}']${listPart}`;
     },
     ScalarTypeDefinition: empty,
     InputObjectTypeDefinition: empty,
@@ -49,7 +56,7 @@ const errorsVisitor: Visitor<any> = {
         return [
             `export class ${ERROR_INTERFACE_NAME} {`,
             `  readonly __typename: string;`,
-            `  readonly code: ErrorCode;`,
+            `  readonly code: string;`,
             ...node.fields.filter(f => !(f as any).includes('code:')).map(f => `${f};`),
             `}`,
         ].join('\n');
@@ -68,7 +75,10 @@ const errorsVisitor: Visitor<any> = {
         return [
             `export class ${node.name.value} extends ${ERROR_INTERFACE_NAME} {`,
             `  readonly __typename = '${node.name.value}';`,
-            `  readonly code = ErrorCode.${camelToUpperSnakeCase(node.name.value)};`,
+            // We cast this to "any" otherwise we need to specify it as type "ErrorCode",
+            // which means shared ErrorResult classes e.g. OrderStateTransitionError
+            // will not be compatible between the admin and shop variations.
+            `  readonly code = '${camelToUpperSnakeCase(node.name.value)}' as any;`,
             `  readonly message = '${camelToUpperSnakeCase(node.name.value)}';`,
             `  constructor(`,
             ...node.fields
@@ -93,7 +103,6 @@ export const plugin: PluginFunction<any> = (schema, documents, config, info) =>
     return {
         content: [
             `/** This file was generated by the graphql-errors-plugin, which is part of the "codegen" npm script. */`,
-            generateErrorCodeImport(schema),
             generateScalars(schema, config),
             ...defs,
             defs.length ? generateIsErrorFunction(schema) : '',
@@ -102,11 +111,6 @@ export const plugin: PluginFunction<any> = (schema, documents, config, info) =>
     };
 };
 
-function generateErrorCodeImport(schema: GraphQLSchema): string {
-    const typesFile = isAdminApi(schema) ? `generated-types` : `generated-shop-types`;
-    return `import { ErrorCode } from '@vendure/common/lib/${typesFile}';`;
-}
-
 function generateScalars(schema: GraphQLSchema, config: any): string {
     const scalarMap = buildScalars(schema, config.scalars);
     const allScalars = Object.keys(scalarMap)

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor