Просмотр исходного кода

feat(core): Implement payment cancellation

Relates to #1637. This commit introduces a new function to the PaymentHandler as well as a new
`cancelPayment` mutation to the Admin API. Rather than directly transitioning the Payment state
to "Cancelled", the intention is that `cancelPayment` should be executed, which will run the
associated PaymentMethodHandler function (if defined).
Michael Bromley 3 лет назад
Родитель
Сommit
1ce1ba7fd6

+ 23 - 1
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -307,6 +307,16 @@ export type CancelOrderInput = {
 
 export type CancelOrderResult = Order | EmptyOrderLineSelectionError | QuantityTooGreatError | MultipleOrderError | CancelActiveOrderError | OrderStateTransitionError;
 
+/** Returned if the Payment cancellation fails */
+export type CancelPaymentError = ErrorResult & {
+  __typename?: 'CancelPaymentError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+  paymentErrorMessage: Scalars['String'];
+};
+
+export type CancelPaymentResult = Payment | CancelPaymentError | PaymentStateTransitionError;
+
 export type Cancellation = Node & StockMovement & {
   __typename?: 'Cancellation';
   id: Scalars['ID'];
@@ -1416,6 +1426,7 @@ export enum ErrorCode {
   LANGUAGE_NOT_AVAILABLE_ERROR = 'LANGUAGE_NOT_AVAILABLE_ERROR',
   CHANNEL_DEFAULT_LANGUAGE_ERROR = 'CHANNEL_DEFAULT_LANGUAGE_ERROR',
   SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
+  CANCEL_PAYMENT_ERROR = 'CANCEL_PAYMENT_ERROR',
   EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
   ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
   INVALID_FULFILLMENT_HANDLER_ERROR = 'INVALID_FULFILLMENT_HANDLER_ERROR',
@@ -2339,6 +2350,7 @@ export type Mutation = {
   authenticate: AuthenticationResult;
   cancelJob: Job;
   cancelOrder: CancelOrderResult;
+  cancelPayment: CancelPaymentResult;
   /** Create a new Administrator */
   createAdministrator: Administrator;
   /** Create a new Asset */
@@ -2604,6 +2616,11 @@ export type MutationCancelOrderArgs = {
 };
 
 
+export type MutationCancelPaymentArgs = {
+  id: Scalars['ID'];
+};
+
+
 export type MutationCreateAdministratorArgs = {
   input: CreateAdministratorInput;
 };
@@ -8893,6 +8910,11 @@ type ErrorResult_CancelActiveOrderError_Fragment = (
   & Pick<CancelActiveOrderError, 'errorCode' | 'message'>
 );
 
+type ErrorResult_CancelPaymentError_Fragment = (
+  { __typename?: 'CancelPaymentError' }
+  & Pick<CancelPaymentError, 'errorCode' | 'message'>
+);
+
 type ErrorResult_ChannelDefaultLanguageError_Fragment = (
   { __typename?: 'ChannelDefaultLanguageError' }
   & Pick<ChannelDefaultLanguageError, 'errorCode' | 'message'>
@@ -9063,7 +9085,7 @@ type ErrorResult_SettlePaymentError_Fragment = (
   & Pick<SettlePaymentError, 'errorCode' | 'message'>
 );
 
-export type ErrorResultFragment = ErrorResult_AlreadyRefundedError_Fragment | ErrorResult_CancelActiveOrderError_Fragment | ErrorResult_ChannelDefaultLanguageError_Fragment | ErrorResult_CouponCodeExpiredError_Fragment | ErrorResult_CouponCodeInvalidError_Fragment | ErrorResult_CouponCodeLimitError_Fragment | ErrorResult_CreateFulfillmentError_Fragment | ErrorResult_EmailAddressConflictError_Fragment | ErrorResult_EmptyOrderLineSelectionError_Fragment | ErrorResult_FulfillmentStateTransitionError_Fragment | ErrorResult_InsufficientStockError_Fragment | ErrorResult_InsufficientStockOnHandError_Fragment | ErrorResult_InvalidCredentialsError_Fragment | ErrorResult_InvalidFulfillmentHandlerError_Fragment | ErrorResult_ItemsAlreadyFulfilledError_Fragment | ErrorResult_LanguageNotAvailableError_Fragment | ErrorResult_ManualPaymentStateError_Fragment | ErrorResult_MimeTypeError_Fragment | ErrorResult_MissingConditionsError_Fragment | ErrorResult_MultipleOrderError_Fragment | ErrorResult_NativeAuthStrategyError_Fragment | ErrorResult_NegativeQuantityError_Fragment | ErrorResult_NoChangesSpecifiedError_Fragment | ErrorResult_NothingToRefundError_Fragment | ErrorResult_OrderLimitError_Fragment | ErrorResult_OrderModificationStateError_Fragment | ErrorResult_OrderStateTransitionError_Fragment | ErrorResult_PaymentMethodMissingError_Fragment | ErrorResult_PaymentOrderMismatchError_Fragment | ErrorResult_PaymentStateTransitionError_Fragment | ErrorResult_ProductOptionInUseError_Fragment | ErrorResult_QuantityTooGreatError_Fragment | ErrorResult_RefundOrderStateError_Fragment | ErrorResult_RefundPaymentIdMissingError_Fragment | ErrorResult_RefundStateTransitionError_Fragment | ErrorResult_SettlePaymentError_Fragment;
+export type ErrorResultFragment = ErrorResult_AlreadyRefundedError_Fragment | ErrorResult_CancelActiveOrderError_Fragment | ErrorResult_CancelPaymentError_Fragment | ErrorResult_ChannelDefaultLanguageError_Fragment | ErrorResult_CouponCodeExpiredError_Fragment | ErrorResult_CouponCodeInvalidError_Fragment | ErrorResult_CouponCodeLimitError_Fragment | ErrorResult_CreateFulfillmentError_Fragment | ErrorResult_EmailAddressConflictError_Fragment | ErrorResult_EmptyOrderLineSelectionError_Fragment | ErrorResult_FulfillmentStateTransitionError_Fragment | ErrorResult_InsufficientStockError_Fragment | ErrorResult_InsufficientStockOnHandError_Fragment | ErrorResult_InvalidCredentialsError_Fragment | ErrorResult_InvalidFulfillmentHandlerError_Fragment | ErrorResult_ItemsAlreadyFulfilledError_Fragment | ErrorResult_LanguageNotAvailableError_Fragment | ErrorResult_ManualPaymentStateError_Fragment | ErrorResult_MimeTypeError_Fragment | ErrorResult_MissingConditionsError_Fragment | ErrorResult_MultipleOrderError_Fragment | ErrorResult_NativeAuthStrategyError_Fragment | ErrorResult_NegativeQuantityError_Fragment | ErrorResult_NoChangesSpecifiedError_Fragment | ErrorResult_NothingToRefundError_Fragment | ErrorResult_OrderLimitError_Fragment | ErrorResult_OrderModificationStateError_Fragment | ErrorResult_OrderStateTransitionError_Fragment | ErrorResult_PaymentMethodMissingError_Fragment | ErrorResult_PaymentOrderMismatchError_Fragment | ErrorResult_PaymentStateTransitionError_Fragment | ErrorResult_ProductOptionInUseError_Fragment | ErrorResult_QuantityTooGreatError_Fragment | ErrorResult_RefundOrderStateError_Fragment | ErrorResult_RefundPaymentIdMissingError_Fragment | ErrorResult_RefundStateTransitionError_Fragment | ErrorResult_SettlePaymentError_Fragment;
 
 export type ShippingMethodFragment = (
   { __typename?: 'ShippingMethod' }

+ 197 - 262
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -1,265 +1,200 @@
 // tslint:disable
 
-      export interface PossibleTypesResultData {
-        possibleTypes: {
-          [key: string]: string[]
-        }
-      }
-      const result: PossibleTypesResultData = {
-  "possibleTypes": {
-    "AddFulfillmentToOrderResult": [
-      "Fulfillment",
-      "EmptyOrderLineSelectionError",
-      "ItemsAlreadyFulfilledError",
-      "InsufficientStockOnHandError",
-      "InvalidFulfillmentHandlerError",
-      "FulfillmentStateTransitionError",
-      "CreateFulfillmentError"
-    ],
-    "AddManualPaymentToOrderResult": [
-      "Order",
-      "ManualPaymentStateError"
-    ],
-    "AuthenticationResult": [
-      "CurrentUser",
-      "InvalidCredentialsError"
-    ],
-    "CancelOrderResult": [
-      "Order",
-      "EmptyOrderLineSelectionError",
-      "QuantityTooGreatError",
-      "MultipleOrderError",
-      "CancelActiveOrderError",
-      "OrderStateTransitionError"
-    ],
-    "CreateAssetResult": [
-      "Asset",
-      "MimeTypeError"
-    ],
-    "CreateChannelResult": [
-      "Channel",
-      "LanguageNotAvailableError"
-    ],
-    "CreateCustomerResult": [
-      "Customer",
-      "EmailAddressConflictError"
-    ],
-    "CreatePromotionResult": [
-      "Promotion",
-      "MissingConditionsError"
-    ],
-    "CustomField": [
-      "BooleanCustomFieldConfig",
-      "DateTimeCustomFieldConfig",
-      "FloatCustomFieldConfig",
-      "IntCustomFieldConfig",
-      "LocaleStringCustomFieldConfig",
-      "RelationCustomFieldConfig",
-      "StringCustomFieldConfig",
-      "TextCustomFieldConfig"
-    ],
-    "CustomFieldConfig": [
-      "StringCustomFieldConfig",
-      "LocaleStringCustomFieldConfig",
-      "IntCustomFieldConfig",
-      "FloatCustomFieldConfig",
-      "BooleanCustomFieldConfig",
-      "DateTimeCustomFieldConfig",
-      "RelationCustomFieldConfig",
-      "TextCustomFieldConfig"
-    ],
-    "ErrorResult": [
-      "AlreadyRefundedError",
-      "CancelActiveOrderError",
-      "ChannelDefaultLanguageError",
-      "CouponCodeExpiredError",
-      "CouponCodeInvalidError",
-      "CouponCodeLimitError",
-      "CreateFulfillmentError",
-      "EmailAddressConflictError",
-      "EmptyOrderLineSelectionError",
-      "FulfillmentStateTransitionError",
-      "InsufficientStockError",
-      "InsufficientStockOnHandError",
-      "InvalidCredentialsError",
-      "InvalidFulfillmentHandlerError",
-      "ItemsAlreadyFulfilledError",
-      "LanguageNotAvailableError",
-      "ManualPaymentStateError",
-      "MimeTypeError",
-      "MissingConditionsError",
-      "MultipleOrderError",
-      "NativeAuthStrategyError",
-      "NegativeQuantityError",
-      "NoChangesSpecifiedError",
-      "NothingToRefundError",
-      "OrderLimitError",
-      "OrderModificationStateError",
-      "OrderStateTransitionError",
-      "PaymentMethodMissingError",
-      "PaymentOrderMismatchError",
-      "PaymentStateTransitionError",
-      "ProductOptionInUseError",
-      "QuantityTooGreatError",
-      "RefundOrderStateError",
-      "RefundPaymentIdMissingError",
-      "RefundStateTransitionError",
-      "SettlePaymentError"
-    ],
-    "ModifyOrderResult": [
-      "Order",
-      "NoChangesSpecifiedError",
-      "OrderModificationStateError",
-      "PaymentMethodMissingError",
-      "RefundPaymentIdMissingError",
-      "OrderLimitError",
-      "NegativeQuantityError",
-      "InsufficientStockError",
-      "CouponCodeExpiredError",
-      "CouponCodeInvalidError",
-      "CouponCodeLimitError"
-    ],
-    "NativeAuthenticationResult": [
-      "CurrentUser",
-      "InvalidCredentialsError",
-      "NativeAuthStrategyError"
-    ],
-    "Node": [
-      "Address",
-      "Administrator",
-      "Allocation",
-      "Asset",
-      "AuthenticationMethod",
-      "Cancellation",
-      "Channel",
-      "Collection",
-      "Country",
-      "Customer",
-      "CustomerGroup",
-      "Facet",
-      "FacetValue",
-      "Fulfillment",
-      "HistoryEntry",
-      "Job",
-      "Order",
-      "OrderItem",
-      "OrderLine",
-      "OrderModification",
-      "Payment",
-      "PaymentMethod",
-      "Product",
-      "ProductOption",
-      "ProductOptionGroup",
-      "ProductVariant",
-      "Promotion",
-      "Refund",
-      "Release",
-      "Return",
-      "Role",
-      "Sale",
-      "ShippingMethod",
-      "StockAdjustment",
-      "Surcharge",
-      "Tag",
-      "TaxCategory",
-      "TaxRate",
-      "User",
-      "Zone"
-    ],
-    "PaginatedList": [
-      "AdministratorList",
-      "AssetList",
-      "CollectionList",
-      "CountryList",
-      "CustomerGroupList",
-      "CustomerList",
-      "FacetList",
-      "HistoryEntryList",
-      "JobList",
-      "OrderList",
-      "PaymentMethodList",
-      "ProductList",
-      "ProductVariantList",
-      "PromotionList",
-      "RoleList",
-      "ShippingMethodList",
-      "TagList",
-      "TaxRateList"
-    ],
-    "RefundOrderResult": [
-      "Refund",
-      "QuantityTooGreatError",
-      "NothingToRefundError",
-      "OrderStateTransitionError",
-      "MultipleOrderError",
-      "PaymentOrderMismatchError",
-      "RefundOrderStateError",
-      "AlreadyRefundedError",
-      "RefundStateTransitionError"
-    ],
-    "RemoveOptionGroupFromProductResult": [
-      "Product",
-      "ProductOptionInUseError"
-    ],
-    "SearchResultPrice": [
-      "PriceRange",
-      "SinglePrice"
-    ],
-    "SettlePaymentResult": [
-      "Payment",
-      "SettlePaymentError",
-      "PaymentStateTransitionError",
-      "OrderStateTransitionError"
-    ],
-    "SettleRefundResult": [
-      "Refund",
-      "RefundStateTransitionError"
-    ],
-    "StockMovement": [
-      "Allocation",
-      "Cancellation",
-      "Release",
-      "Return",
-      "Sale",
-      "StockAdjustment"
-    ],
-    "StockMovementItem": [
-      "StockAdjustment",
-      "Allocation",
-      "Sale",
-      "Cancellation",
-      "Return",
-      "Release"
-    ],
-    "TransitionFulfillmentToStateResult": [
-      "Fulfillment",
-      "FulfillmentStateTransitionError"
-    ],
-    "TransitionOrderToStateResult": [
-      "Order",
-      "OrderStateTransitionError"
-    ],
-    "TransitionPaymentToStateResult": [
-      "Payment",
-      "PaymentStateTransitionError"
-    ],
-    "UpdateChannelResult": [
-      "Channel",
-      "LanguageNotAvailableError"
-    ],
-    "UpdateCustomerResult": [
-      "Customer",
-      "EmailAddressConflictError"
-    ],
-    "UpdateGlobalSettingsResult": [
-      "GlobalSettings",
-      "ChannelDefaultLanguageError"
-    ],
-    "UpdatePromotionResult": [
-      "Promotion",
-      "MissingConditionsError"
-    ]
-  }
+export interface PossibleTypesResultData {
+    possibleTypes: {
+        [key: string]: string[];
+    };
+}
+const result: PossibleTypesResultData = {
+    possibleTypes: {
+        AddFulfillmentToOrderResult: [
+            'Fulfillment',
+            'EmptyOrderLineSelectionError',
+            'ItemsAlreadyFulfilledError',
+            'InsufficientStockOnHandError',
+            'InvalidFulfillmentHandlerError',
+            'FulfillmentStateTransitionError',
+            'CreateFulfillmentError',
+        ],
+        AddManualPaymentToOrderResult: ['Order', 'ManualPaymentStateError'],
+        AuthenticationResult: ['CurrentUser', 'InvalidCredentialsError'],
+        CancelOrderResult: [
+            'Order',
+            'EmptyOrderLineSelectionError',
+            'QuantityTooGreatError',
+            'MultipleOrderError',
+            'CancelActiveOrderError',
+            'OrderStateTransitionError',
+        ],
+        CancelPaymentResult: ['Payment', 'CancelPaymentError', 'PaymentStateTransitionError'],
+        CreateAssetResult: ['Asset', 'MimeTypeError'],
+        CreateChannelResult: ['Channel', 'LanguageNotAvailableError'],
+        CreateCustomerResult: ['Customer', 'EmailAddressConflictError'],
+        CreatePromotionResult: ['Promotion', 'MissingConditionsError'],
+        CustomField: [
+            'BooleanCustomFieldConfig',
+            'DateTimeCustomFieldConfig',
+            'FloatCustomFieldConfig',
+            'IntCustomFieldConfig',
+            'LocaleStringCustomFieldConfig',
+            'RelationCustomFieldConfig',
+            'StringCustomFieldConfig',
+            'TextCustomFieldConfig',
+        ],
+        CustomFieldConfig: [
+            'StringCustomFieldConfig',
+            'LocaleStringCustomFieldConfig',
+            'IntCustomFieldConfig',
+            'FloatCustomFieldConfig',
+            'BooleanCustomFieldConfig',
+            'DateTimeCustomFieldConfig',
+            'RelationCustomFieldConfig',
+            'TextCustomFieldConfig',
+        ],
+        ErrorResult: [
+            'AlreadyRefundedError',
+            'CancelActiveOrderError',
+            'CancelPaymentError',
+            'ChannelDefaultLanguageError',
+            'CouponCodeExpiredError',
+            'CouponCodeInvalidError',
+            'CouponCodeLimitError',
+            'CreateFulfillmentError',
+            'EmailAddressConflictError',
+            'EmptyOrderLineSelectionError',
+            'FulfillmentStateTransitionError',
+            'InsufficientStockError',
+            'InsufficientStockOnHandError',
+            'InvalidCredentialsError',
+            'InvalidFulfillmentHandlerError',
+            'ItemsAlreadyFulfilledError',
+            'LanguageNotAvailableError',
+            'ManualPaymentStateError',
+            'MimeTypeError',
+            'MissingConditionsError',
+            'MultipleOrderError',
+            'NativeAuthStrategyError',
+            'NegativeQuantityError',
+            'NoChangesSpecifiedError',
+            'NothingToRefundError',
+            'OrderLimitError',
+            'OrderModificationStateError',
+            'OrderStateTransitionError',
+            'PaymentMethodMissingError',
+            'PaymentOrderMismatchError',
+            'PaymentStateTransitionError',
+            'ProductOptionInUseError',
+            'QuantityTooGreatError',
+            'RefundOrderStateError',
+            'RefundPaymentIdMissingError',
+            'RefundStateTransitionError',
+            'SettlePaymentError',
+        ],
+        ModifyOrderResult: [
+            'Order',
+            'NoChangesSpecifiedError',
+            'OrderModificationStateError',
+            'PaymentMethodMissingError',
+            'RefundPaymentIdMissingError',
+            'OrderLimitError',
+            'NegativeQuantityError',
+            'InsufficientStockError',
+            'CouponCodeExpiredError',
+            'CouponCodeInvalidError',
+            'CouponCodeLimitError',
+        ],
+        NativeAuthenticationResult: ['CurrentUser', 'InvalidCredentialsError', 'NativeAuthStrategyError'],
+        Node: [
+            'Address',
+            'Administrator',
+            'Allocation',
+            'Asset',
+            'AuthenticationMethod',
+            'Cancellation',
+            'Channel',
+            'Collection',
+            'Country',
+            'Customer',
+            'CustomerGroup',
+            'Facet',
+            'FacetValue',
+            'Fulfillment',
+            'HistoryEntry',
+            'Job',
+            'Order',
+            'OrderItem',
+            'OrderLine',
+            'OrderModification',
+            'Payment',
+            'PaymentMethod',
+            'Product',
+            'ProductOption',
+            'ProductOptionGroup',
+            'ProductVariant',
+            'Promotion',
+            'Refund',
+            'Release',
+            'Return',
+            'Role',
+            'Sale',
+            'ShippingMethod',
+            'StockAdjustment',
+            'Surcharge',
+            'Tag',
+            'TaxCategory',
+            'TaxRate',
+            'User',
+            'Zone',
+        ],
+        PaginatedList: [
+            'AdministratorList',
+            'AssetList',
+            'CollectionList',
+            'CountryList',
+            'CustomerGroupList',
+            'CustomerList',
+            'FacetList',
+            'HistoryEntryList',
+            'JobList',
+            'OrderList',
+            'PaymentMethodList',
+            'ProductList',
+            'ProductVariantList',
+            'PromotionList',
+            'RoleList',
+            'ShippingMethodList',
+            'TagList',
+            'TaxRateList',
+        ],
+        RefundOrderResult: [
+            'Refund',
+            'QuantityTooGreatError',
+            'NothingToRefundError',
+            'OrderStateTransitionError',
+            'MultipleOrderError',
+            'PaymentOrderMismatchError',
+            'RefundOrderStateError',
+            'AlreadyRefundedError',
+            'RefundStateTransitionError',
+        ],
+        RemoveOptionGroupFromProductResult: ['Product', 'ProductOptionInUseError'],
+        SearchResultPrice: ['PriceRange', 'SinglePrice'],
+        SettlePaymentResult: [
+            'Payment',
+            'SettlePaymentError',
+            'PaymentStateTransitionError',
+            'OrderStateTransitionError',
+        ],
+        SettleRefundResult: ['Refund', 'RefundStateTransitionError'],
+        StockMovement: ['Allocation', 'Cancellation', 'Release', 'Return', 'Sale', 'StockAdjustment'],
+        StockMovementItem: ['StockAdjustment', 'Allocation', 'Sale', 'Cancellation', 'Return', 'Release'],
+        TransitionFulfillmentToStateResult: ['Fulfillment', 'FulfillmentStateTransitionError'],
+        TransitionOrderToStateResult: ['Order', 'OrderStateTransitionError'],
+        TransitionPaymentToStateResult: ['Payment', 'PaymentStateTransitionError'],
+        UpdateChannelResult: ['Channel', 'LanguageNotAvailableError'],
+        UpdateCustomerResult: ['Customer', 'EmailAddressConflictError'],
+        UpdateGlobalSettingsResult: ['GlobalSettings', 'ChannelDefaultLanguageError'],
+        UpdatePromotionResult: ['Promotion', 'MissingConditionsError'],
+    },
 };
-      export default result;
-    
+export default result;

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

@@ -309,6 +309,15 @@ export type CancelOrderResult =
     | CancelActiveOrderError
     | OrderStateTransitionError;
 
+/** Returned if the Payment cancellation fails */
+export type CancelPaymentError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    paymentErrorMessage: Scalars['String'];
+};
+
+export type CancelPaymentResult = Payment | CancelPaymentError | PaymentStateTransitionError;
+
 export type Cancellation = Node &
     StockMovement & {
         id: Scalars['ID'];
@@ -1383,6 +1392,7 @@ export enum ErrorCode {
     LANGUAGE_NOT_AVAILABLE_ERROR = 'LANGUAGE_NOT_AVAILABLE_ERROR',
     CHANNEL_DEFAULT_LANGUAGE_ERROR = 'CHANNEL_DEFAULT_LANGUAGE_ERROR',
     SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
+    CANCEL_PAYMENT_ERROR = 'CANCEL_PAYMENT_ERROR',
     EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
     ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
     INVALID_FULFILLMENT_HANDLER_ERROR = 'INVALID_FULFILLMENT_HANDLER_ERROR',
@@ -2341,6 +2351,7 @@ export type Mutation = {
     cancelJob: Job;
     flushBufferedJobs: Success;
     settlePayment: SettlePaymentResult;
+    cancelPayment: CancelPaymentResult;
     addFulfillmentToOrder: AddFulfillmentToOrderResult;
     cancelOrder: CancelOrderResult;
     refundOrder: RefundOrderResult;
@@ -2661,6 +2672,10 @@ export type MutationSettlePaymentArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationCancelPaymentArgs = {
+    id: Scalars['ID'];
+};
+
 export type MutationAddFulfillmentToOrderArgs = {
     input: FulfillOrderInput;
 };

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

@@ -306,6 +306,16 @@ export type CancelOrderInput = {
 
 export type CancelOrderResult = Order | EmptyOrderLineSelectionError | QuantityTooGreatError | MultipleOrderError | CancelActiveOrderError | OrderStateTransitionError;
 
+/** Returned if the Payment cancellation fails */
+export type CancelPaymentError = ErrorResult & {
+  __typename?: 'CancelPaymentError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+  paymentErrorMessage: Scalars['String'];
+};
+
+export type CancelPaymentResult = Payment | CancelPaymentError | PaymentStateTransitionError;
+
 export type Cancellation = Node & StockMovement & {
   __typename?: 'Cancellation';
   id: Scalars['ID'];
@@ -1408,6 +1418,7 @@ export enum ErrorCode {
   LANGUAGE_NOT_AVAILABLE_ERROR = 'LANGUAGE_NOT_AVAILABLE_ERROR',
   CHANNEL_DEFAULT_LANGUAGE_ERROR = 'CHANNEL_DEFAULT_LANGUAGE_ERROR',
   SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
+  CANCEL_PAYMENT_ERROR = 'CANCEL_PAYMENT_ERROR',
   EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
   ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
   INVALID_FULFILLMENT_HANDLER_ERROR = 'INVALID_FULFILLMENT_HANDLER_ERROR',
@@ -2387,6 +2398,7 @@ export type Mutation = {
   cancelJob: Job;
   flushBufferedJobs: Success;
   settlePayment: SettlePaymentResult;
+  cancelPayment: CancelPaymentResult;
   addFulfillmentToOrder: AddFulfillmentToOrderResult;
   cancelOrder: CancelOrderResult;
   refundOrder: RefundOrderResult;
@@ -2756,6 +2768,11 @@ export type MutationSettlePaymentArgs = {
 };
 
 
+export type MutationCancelPaymentArgs = {
+  id: Scalars['ID'];
+};
+
+
 export type MutationAddFulfillmentToOrderArgs = {
   input: FulfillOrderInput;
 };

+ 42 - 0
packages/core/e2e/fixtures/test-payment-methods.ts

@@ -20,6 +20,7 @@ export const testSuccessfulPaymentMethod = new PaymentMethodHandler({
 });
 
 export const onTransitionSpy = jest.fn();
+export const onCancelPaymentSpy = jest.fn();
 /**
  * A two-stage (authorize, capture) payment method, with no createRefund method.
  */
@@ -43,6 +44,15 @@ export const twoStagePaymentMethod = new PaymentMethodHandler({
             },
         };
     },
+    cancelPayment: (...args) => {
+        onCancelPaymentSpy(...args);
+        return {
+            success: true,
+            metadata: {
+                cancellationCode: '12345',
+            },
+        };
+    },
     onStateTransitionStart: (fromState, toState, data) => {
         onTransitionSpy(fromState, toState, data);
     },
@@ -166,6 +176,38 @@ export const failsToSettlePaymentMethod = new PaymentMethodHandler({
         };
     },
 });
+
+/**
+ * A payment method where calling `settlePayment` always fails.
+ */
+export const failsToCancelPaymentMethod = new PaymentMethodHandler({
+    code: 'fails-to-cancel-payment-method',
+    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
+    args: {},
+    createPayment: (ctx, order, amount, args, metadata) => {
+        return {
+            amount,
+            state: 'Authorized',
+            transactionId: '12345-' + order.code,
+        };
+    },
+    settlePayment: () => {
+        return {
+            success: true,
+        };
+    },
+    cancelPayment: (ctx, order, payment) => {
+        return {
+            success: false,
+            errorMessage: 'something went horribly wrong',
+            state: payment.state !== 'Cancelled' ? payment.state : undefined,
+            metadata: {
+                cancellationData: 'foo',
+            },
+        };
+    },
+});
+
 export const testFailingPaymentMethod = new PaymentMethodHandler({
     code: 'test-failing-payment-method',
     description: [{ languageCode: LanguageCode.en, value: 'Test Failing Payment Method' }],

+ 71 - 27
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -309,6 +309,15 @@ export type CancelOrderResult =
     | CancelActiveOrderError
     | OrderStateTransitionError;
 
+/** Returned if the Payment cancellation fails */
+export type CancelPaymentError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    paymentErrorMessage: Scalars['String'];
+};
+
+export type CancelPaymentResult = Payment | CancelPaymentError | PaymentStateTransitionError;
+
 export type Cancellation = Node &
     StockMovement & {
         id: Scalars['ID'];
@@ -1383,6 +1392,7 @@ export enum ErrorCode {
     LANGUAGE_NOT_AVAILABLE_ERROR = 'LANGUAGE_NOT_AVAILABLE_ERROR',
     CHANNEL_DEFAULT_LANGUAGE_ERROR = 'CHANNEL_DEFAULT_LANGUAGE_ERROR',
     SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
+    CANCEL_PAYMENT_ERROR = 'CANCEL_PAYMENT_ERROR',
     EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
     ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
     INVALID_FULFILLMENT_HANDLER_ERROR = 'INVALID_FULFILLMENT_HANDLER_ERROR',
@@ -2341,6 +2351,7 @@ export type Mutation = {
     cancelJob: Job;
     flushBufferedJobs: Success;
     settlePayment: SettlePaymentResult;
+    cancelPayment: CancelPaymentResult;
     addFulfillmentToOrder: AddFulfillmentToOrderResult;
     cancelOrder: CancelOrderResult;
     refundOrder: RefundOrderResult;
@@ -2661,6 +2672,10 @@ export type MutationSettlePaymentArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationCancelPaymentArgs = {
+    id: Scalars['ID'];
+};
+
 export type MutationAddFulfillmentToOrderArgs = {
     input: FulfillOrderInput;
 };
@@ -6524,6 +6539,17 @@ export type GetCollectionsQuery = {
     };
 };
 
+export type TransitionPaymentToStateMutationVariables = Exact<{
+    id: Scalars['ID'];
+    state: Scalars['String'];
+}>;
+
+export type TransitionPaymentToStateMutation = {
+    transitionPaymentToState:
+        | PaymentFragment
+        | Pick<PaymentStateTransitionError, 'errorCode' | 'message' | 'transitionError'>;
+};
+
 export type CancelJobMutationVariables = Exact<{
     id: Scalars['ID'];
 }>;
@@ -6762,6 +6788,17 @@ export type GetOrderListWithQtyQuery = {
     };
 };
 
+export type CancelPaymentMutationVariables = Exact<{
+    paymentId: Scalars['ID'];
+}>;
+
+export type CancelPaymentMutation = {
+    cancelPayment:
+        | PaymentFragment
+        | Pick<CancelPaymentError, 'errorCode' | 'message' | 'paymentErrorMessage'>
+        | Pick<PaymentStateTransitionError, 'errorCode' | 'message' | 'transitionError'>;
+};
+
 export type PaymentMethodFragment = Pick<
     PaymentMethod,
     'id' | 'code' | 'name' | 'description' | 'enabled'
@@ -6825,17 +6862,6 @@ export type DeletePaymentMethodMutation = {
     deletePaymentMethod: Pick<DeletionResponse, 'message' | 'result'>;
 };
 
-export type TransitionPaymentToStateMutationVariables = Exact<{
-    id: Scalars['ID'];
-    state: Scalars['String'];
-}>;
-
-export type TransitionPaymentToStateMutation = {
-    transitionPaymentToState:
-        | PaymentFragment
-        | Pick<PaymentStateTransitionError, 'errorCode' | 'message' | 'transitionError'>;
-};
-
 export type AddManualPayment2MutationVariables = Exact<{
     input: ManualPaymentInput;
 }>;
@@ -8956,6 +8982,22 @@ export namespace GetCollections {
     >;
 }
 
+export namespace TransitionPaymentToState {
+    export type Variables = TransitionPaymentToStateMutationVariables;
+    export type Mutation = TransitionPaymentToStateMutation;
+    export type TransitionPaymentToState = NonNullable<
+        TransitionPaymentToStateMutation['transitionPaymentToState']
+    >;
+    export type ErrorResultInlineFragment = DiscriminateUnion<
+        NonNullable<TransitionPaymentToStateMutation['transitionPaymentToState']>,
+        { __typename?: 'ErrorResult' }
+    >;
+    export type PaymentStateTransitionErrorInlineFragment = DiscriminateUnion<
+        NonNullable<TransitionPaymentToStateMutation['transitionPaymentToState']>,
+        { __typename?: 'PaymentStateTransitionError' }
+    >;
+}
+
 export namespace CancelJob {
     export type Variables = CancelJobMutationVariables;
     export type Mutation = CancelJobMutation;
@@ -9184,6 +9226,24 @@ export namespace GetOrderListWithQty {
     >;
 }
 
+export namespace CancelPayment {
+    export type Variables = CancelPaymentMutationVariables;
+    export type Mutation = CancelPaymentMutation;
+    export type CancelPayment = NonNullable<CancelPaymentMutation['cancelPayment']>;
+    export type ErrorResultInlineFragment = DiscriminateUnion<
+        NonNullable<CancelPaymentMutation['cancelPayment']>,
+        { __typename?: 'ErrorResult' }
+    >;
+    export type PaymentStateTransitionErrorInlineFragment = DiscriminateUnion<
+        NonNullable<CancelPaymentMutation['cancelPayment']>,
+        { __typename?: 'PaymentStateTransitionError' }
+    >;
+    export type CancelPaymentErrorInlineFragment = DiscriminateUnion<
+        NonNullable<CancelPaymentMutation['cancelPayment']>,
+        { __typename?: 'CancelPaymentError' }
+    >;
+}
+
 export namespace PaymentMethod {
     export type Fragment = PaymentMethodFragment;
     export type Checker = NonNullable<PaymentMethodFragment['checker']>;
@@ -9257,22 +9317,6 @@ export namespace DeletePaymentMethod {
     export type DeletePaymentMethod = NonNullable<DeletePaymentMethodMutation['deletePaymentMethod']>;
 }
 
-export namespace TransitionPaymentToState {
-    export type Variables = TransitionPaymentToStateMutationVariables;
-    export type Mutation = TransitionPaymentToStateMutation;
-    export type TransitionPaymentToState = NonNullable<
-        TransitionPaymentToStateMutation['transitionPaymentToState']
-    >;
-    export type ErrorResultInlineFragment = DiscriminateUnion<
-        NonNullable<TransitionPaymentToStateMutation['transitionPaymentToState']>,
-        { __typename?: 'ErrorResult' }
-    >;
-    export type PaymentStateTransitionErrorInlineFragment = DiscriminateUnion<
-        NonNullable<TransitionPaymentToStateMutation['transitionPaymentToState']>,
-        { __typename?: 'PaymentStateTransitionError' }
-    >;
-}
-
 export namespace AddManualPayment2 {
     export type Variables = AddManualPayment2MutationVariables;
     export type Mutation = AddManualPayment2Mutation;

+ 16 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -950,3 +950,19 @@ export const GET_COLLECTIONS = gql`
         }
     }
 `;
+
+export const TRANSITION_PAYMENT_TO_STATE = gql`
+    mutation TransitionPaymentToState($id: ID!, $state: String!) {
+        transitionPaymentToState(id: $id, state: $state) {
+            ...Payment
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+            ... on PaymentStateTransitionError {
+                transitionError
+            }
+        }
+    }
+    ${PAYMENT_FRAGMENT}
+`;

+ 88 - 1
packages/core/e2e/order.e2e-spec.ts

@@ -20,18 +20,22 @@ import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import {
+    failsToCancelPaymentMethod,
     failsToSettlePaymentMethod,
+    onCancelPaymentSpy,
     onTransitionSpy,
     partialPaymentMethod,
     singleStageRefundablePaymentMethod,
     singleStageRefundFailingPaymentMethod,
     twoStagePaymentMethod,
 } from './fixtures/test-payment-methods';
-import { FULFILLMENT_FRAGMENT } from './graphql/fragments';
+import { FULFILLMENT_FRAGMENT, PAYMENT_FRAGMENT } from './graphql/fragments';
 import {
     AddNoteToOrder,
     CanceledOrderFragment,
     CancelOrder,
+    CancelPaymentMutation,
+    CancelPaymentMutationVariables,
     CreateFulfillment,
     CreateShippingMethod,
     DeleteOrderNote,
@@ -47,6 +51,8 @@ import {
     GetOrderList,
     GetOrderListFulfillments,
     GetOrderListWithQty,
+    GetOrderQuery,
+    GetOrderQueryVariables,
     GetOrderWithPayments,
     GetProductWithVariants,
     GetStockMovement,
@@ -62,6 +68,8 @@ import {
     SortOrder,
     StockMovementType,
     TransitFulfillment,
+    TransitionPaymentToStateMutation,
+    TransitionPaymentToStateMutationVariables,
     UpdateOrderNote,
     UpdateProductVariants,
 } from './graphql/generated-e2e-admin-types';
@@ -95,6 +103,7 @@ import {
     GET_PRODUCT_WITH_VARIANTS,
     GET_STOCK_MOVEMENT,
     SETTLE_PAYMENT,
+    TRANSITION_PAYMENT_TO_STATE,
     TRANSIT_FULFILLMENT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
@@ -123,6 +132,7 @@ describe('Orders resolver', () => {
                     singleStageRefundablePaymentMethod,
                     partialPaymentMethod,
                     singleStageRefundFailingPaymentMethod,
+                    failsToCancelPaymentMethod,
                 ],
             },
         }),
@@ -152,6 +162,10 @@ describe('Orders resolver', () => {
                         name: failsToSettlePaymentMethod.code,
                         handler: { code: failsToSettlePaymentMethod.code, arguments: [] },
                     },
+                    {
+                        name: failsToCancelPaymentMethod.code,
+                        handler: { code: failsToCancelPaymentMethod.code, arguments: [] },
+                    },
                     {
                         name: singleStageRefundablePaymentMethod.code,
                         handler: { code: singleStageRefundablePaymentMethod.code, arguments: [] },
@@ -1803,6 +1817,60 @@ describe('Orders resolver', () => {
         });
     });
 
+    describe('payment cancellation', () => {
+        it("cancelling payment calls the method's cancelPayment handler", async () => {
+            await createTestOrder(adminClient, shopClient, customers[0].emailAddress, password);
+            await proceedToArrangingPayment(shopClient);
+            const order = await addPaymentToOrder(shopClient, twoStagePaymentMethod);
+            orderGuard.assertSuccess(order);
+
+            expect(order.state).toBe('PaymentAuthorized');
+            const paymentId = order.payments![0].id;
+
+            expect(onCancelPaymentSpy).not.toHaveBeenCalled();
+
+            const { cancelPayment } = await adminClient.query<
+                CancelPaymentMutation,
+                CancelPaymentMutationVariables
+            >(CANCEL_PAYMENT, {
+                paymentId,
+            });
+
+            paymentGuard.assertSuccess(cancelPayment);
+            expect(cancelPayment.state).toBe('Cancelled');
+            expect(cancelPayment.metadata.cancellationCode).toBe('12345');
+            expect(onCancelPaymentSpy).toHaveBeenCalledTimes(1);
+        });
+
+        it('cancellation failure', async () => {
+            await createTestOrder(adminClient, shopClient, customers[0].emailAddress, password);
+            await proceedToArrangingPayment(shopClient);
+            const order = await addPaymentToOrder(shopClient, failsToCancelPaymentMethod);
+            orderGuard.assertSuccess(order);
+
+            expect(order.state).toBe('PaymentAuthorized');
+            const paymentId = order.payments![0].id;
+
+            const { cancelPayment } = await adminClient.query<
+                CancelPaymentMutation,
+                CancelPaymentMutationVariables
+            >(CANCEL_PAYMENT, {
+                paymentId,
+            });
+
+            paymentGuard.assertErrorResult(cancelPayment);
+            expect(cancelPayment.message).toBe('Cancelling the payment failed');
+            const { order: checkorder } = await adminClient.query<GetOrderQuery, GetOrderQueryVariables>(
+                GET_ORDER,
+                {
+                    id: order.id,
+                },
+            );
+            expect(checkorder!.payments![0].state).toBe('Authorized');
+            expect(checkorder!.payments![0].metadata).toEqual({ cancellationData: 'foo' });
+        });
+    });
+
     describe('order notes', () => {
         let orderId: string;
         let firstNoteId: string;
@@ -2609,3 +2677,22 @@ const GET_ORDERS_LIST_WITH_QUANTITIES = gql`
         }
     }
 `;
+
+const CANCEL_PAYMENT = gql`
+    mutation CancelPayment($paymentId: ID!) {
+        cancelPayment(id: $paymentId) {
+            ...Payment
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+            ... on PaymentStateTransitionError {
+                transitionError
+            }
+            ... on CancelPaymentError {
+                paymentErrorMessage
+            }
+        }
+    }
+    ${PAYMENT_FRAGMENT}
+`;

+ 6 - 18
packages/core/e2e/payment-process.e2e-spec.ts

@@ -19,7 +19,7 @@ import path from 'path';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
-import { ORDER_WITH_LINES_FRAGMENT, PAYMENT_FRAGMENT } from './graphql/fragments';
+import { ORDER_WITH_LINES_FRAGMENT } from './graphql/fragments';
 import {
     AddManualPayment2,
     AdminTransition,
@@ -35,7 +35,11 @@ import {
     GetActiveOrder,
     TestOrderFragmentFragment,
 } from './graphql/generated-e2e-shop-types';
-import { ADMIN_TRANSITION_TO_STATE, GET_ORDER } from './graphql/shared-definitions';
+import {
+    ADMIN_TRANSITION_TO_STATE,
+    GET_ORDER,
+    TRANSITION_PAYMENT_TO_STATE,
+} from './graphql/shared-definitions';
 import { ADD_ITEM_TO_ORDER, ADD_PAYMENT, GET_ACTIVE_ORDER } from './graphql/shop-definitions';
 import { proceedToArrangingPayment } from './utils/test-order-utils';
 
@@ -381,22 +385,6 @@ describe('Payment process', () => {
     });
 });
 
-const TRANSITION_PAYMENT_TO_STATE = gql`
-    mutation TransitionPaymentToState($id: ID!, $state: String!) {
-        transitionPaymentToState(id: $id, state: $state) {
-            ...Payment
-            ... on ErrorResult {
-                errorCode
-                message
-            }
-            ... on PaymentStateTransitionError {
-                transitionError
-            }
-        }
-    }
-    ${PAYMENT_FRAGMENT}
-`;
-
 export const ADD_MANUAL_PAYMENT = gql`
     mutation AddManualPayment2($input: ManualPaymentInput!) {
         addManualPaymentToOrder(input: $input) {

+ 15 - 6
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -2,10 +2,12 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     AddFulfillmentToOrderResult,
     CancelOrderResult,
+    CancelPaymentResult,
     MutationAddFulfillmentToOrderArgs,
     MutationAddManualPaymentToOrderArgs,
     MutationAddNoteToOrderArgs,
     MutationCancelOrderArgs,
+    MutationCancelPaymentArgs,
     MutationDeleteOrderNoteArgs,
     MutationModifyOrderArgs,
     MutationRefundOrderArgs,
@@ -24,9 +26,9 @@ import {
     TransitionPaymentToStateResult,
 } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
-import { TransactionalConnection } from '../../../connection';
 
 import { ErrorResultUnion, isGraphQlErrorResult } from '../../../common/error/error-result';
+import { TransactionalConnection } from '../../../connection';
 import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
 import { Order } from '../../../entity/order/order.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
@@ -43,10 +45,7 @@ import { Transaction } from '../../decorators/transaction.decorator';
 
 @Resolver()
 export class OrderResolver {
-    constructor(
-        private orderService: OrderService, 
-        private connection: TransactionalConnection
-    ) {}
+    constructor(private orderService: OrderService, private connection: TransactionalConnection) {}
 
     @Query()
     @Allow(Permission.ReadOrder)
@@ -78,6 +77,16 @@ export class OrderResolver {
         return this.orderService.settlePayment(ctx, args.id);
     }
 
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdateOrder)
+    async cancelPayment(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationCancelPaymentArgs,
+    ): Promise<ErrorResultUnion<CancelPaymentResult, Payment>> {
+        return this.orderService.cancelPayment(ctx, args.id);
+    }
+
     @Transaction()
     @Mutation()
     @Allow(Permission.UpdateOrder)
@@ -186,7 +195,7 @@ export class OrderResolver {
             await this.connection.commitOpenTransaction(ctx);
         }
 
-        return result
+        return result;
     }
 
     @Transaction()

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

@@ -5,6 +5,7 @@ type Query {
 
 type Mutation {
     settlePayment(id: ID!): SettlePaymentResult!
+    cancelPayment(id: ID!): CancelPaymentResult!
     addFulfillmentToOrder(input: FulfillOrderInput!): AddFulfillmentToOrderResult!
     cancelOrder(input: CancelOrderInput!): CancelOrderResult!
     refundOrder(input: RefundOrderInput!): RefundOrderResult!
@@ -179,6 +180,13 @@ type SettlePaymentError implements ErrorResult {
     paymentErrorMessage: String!
 }
 
+"Returned if the Payment cancellation fails"
+type CancelPaymentError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    paymentErrorMessage: String!
+}
+
 "Returned if no OrderLines have been specified for the operation"
 type EmptyOrderLineSelectionError implements ErrorResult {
     errorCode: ErrorCode!
@@ -333,6 +341,7 @@ union SettlePaymentResult =
     | SettlePaymentError
     | PaymentStateTransitionError
     | OrderStateTransitionError
+union CancelPaymentResult = Payment | CancelPaymentError | PaymentStateTransitionError
 union AddFulfillmentToOrderResult =
       Fulfillment
     | EmptyOrderLineSelectionError

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

@@ -40,6 +40,17 @@ export class CancelActiveOrderError extends ErrorResult {
   }
 }
 
+export class CancelPaymentError extends ErrorResult {
+  readonly __typename = 'CancelPaymentError';
+  readonly errorCode = 'CANCEL_PAYMENT_ERROR' as any;
+  readonly message = 'CANCEL_PAYMENT_ERROR';
+  constructor(
+    public paymentErrorMessage: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
 export class ChannelDefaultLanguageError extends ErrorResult {
   readonly __typename = 'ChannelDefaultLanguageError';
   readonly errorCode = 'CHANNEL_DEFAULT_LANGUAGE_ERROR' as any;
@@ -414,7 +425,7 @@ export class SettlePaymentError extends ErrorResult {
 }
 
 
-const errorTypeNames = new Set(['AlreadyRefundedError', 'CancelActiveOrderError', 'ChannelDefaultLanguageError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'CreateFulfillmentError', 'EmailAddressConflictError', 'EmptyOrderLineSelectionError', 'FulfillmentStateTransitionError', 'InsufficientStockError', 'InsufficientStockOnHandError', 'InvalidCredentialsError', 'InvalidFulfillmentHandlerError', 'ItemsAlreadyFulfilledError', 'LanguageNotAvailableError', 'ManualPaymentStateError', 'MimeTypeError', 'MissingConditionsError', 'MultipleOrderError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoChangesSpecifiedError', 'NothingToRefundError', 'OrderLimitError', 'OrderModificationStateError', 'OrderStateTransitionError', 'PaymentMethodMissingError', 'PaymentOrderMismatchError', 'PaymentStateTransitionError', 'ProductOptionInUseError', 'QuantityTooGreatError', 'RefundOrderStateError', 'RefundPaymentIdMissingError', 'RefundStateTransitionError', 'SettlePaymentError']);
+const errorTypeNames = new Set(['AlreadyRefundedError', 'CancelActiveOrderError', 'CancelPaymentError', 'ChannelDefaultLanguageError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'CreateFulfillmentError', 'EmailAddressConflictError', 'EmptyOrderLineSelectionError', 'FulfillmentStateTransitionError', 'InsufficientStockError', 'InsufficientStockOnHandError', 'InvalidCredentialsError', 'InvalidFulfillmentHandlerError', 'ItemsAlreadyFulfilledError', 'LanguageNotAvailableError', 'ManualPaymentStateError', 'MimeTypeError', 'MissingConditionsError', 'MultipleOrderError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoChangesSpecifiedError', 'NothingToRefundError', 'OrderLimitError', 'OrderModificationStateError', 'OrderStateTransitionError', 'PaymentMethodMissingError', 'PaymentOrderMismatchError', 'PaymentStateTransitionError', 'ProductOptionInUseError', 'QuantityTooGreatError', 'RefundOrderStateError', 'RefundPaymentIdMissingError', 'RefundStateTransitionError', 'SettlePaymentError']);
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
 }
@@ -465,6 +476,11 @@ export const adminErrorOperationTypeResolvers = {
       return isGraphQLError(value) ? (value as any).__typename : 'Payment';
     },
   },
+  CancelPaymentResult: {
+    __resolveType(value: any) {
+      return isGraphQLError(value) ? (value as any).__typename : 'Payment';
+    },
+  },
   AddFulfillmentToOrderResult: {
     __resolveType(value: any) {
       return isGraphQLError(value) ? (value as any).__typename : 'Fulfillment';

+ 86 - 4
packages/core/src/config/payment/payment-method-handler.ts

@@ -142,6 +142,45 @@ export interface SettlePaymentErrorResult {
     metadata?: PaymentMetadata;
 }
 
+/**
+ * @description
+ * This object is the return value of the {@link CancelPaymentFn} when the Payment
+ * has been successfully cancelled.
+ *
+ * @docsCategory payment
+ * @docsPage Payment Method Types
+ */
+export interface CancelPaymentResult {
+    success: true;
+    metadata?: PaymentMetadata;
+}
+/**
+ * @description
+ * This object is the return value of the {@link CancelPaymentFn} when the Payment
+ * could not be cancelled.
+ *
+ * @docsCategory payment
+ * @docsPage Payment Method Types
+ */
+export interface CancelPaymentErrorResult {
+    success: false;
+    /**
+     * @description
+     * The state to transition this Payment to upon unsuccessful cancellation.
+     * Defaults to `Error`. Note that if using a different state, it must be
+     * legal to transition to that state from the `Authorized` state according
+     * to the PaymentState config (which can be customized using the
+     * {@link CustomPaymentProcess}).
+     */
+    state?: Exclude<PaymentState, 'Cancelled'>;
+    /**
+     * @description
+     * The message that will be returned when attempting to cancel the payment, and will
+     * also be persisted as `Payment.errorMessage`.
+     */
+    errorMessage?: string;
+    metadata?: PaymentMetadata;
+}
 /**
  * @description
  * This function contains the logic for creating a payment. See {@link PaymentMethodHandler} for an example.
@@ -175,6 +214,21 @@ export type SettlePaymentFn<T extends ConfigArgs> = (
     method: PaymentMethod,
 ) => SettlePaymentResult | SettlePaymentErrorResult | Promise<SettlePaymentResult | SettlePaymentErrorResult>;
 
+/**
+ * @description
+ * This function contains the logic for cancelling a payment. See {@link PaymentMethodHandler} for an example.
+ *
+ * @docsCategory payment
+ * @docsPage Payment Method Types
+ */
+export type CancelPaymentFn<T extends ConfigArgs> = (
+    ctx: RequestContext,
+    order: Order,
+    payment: Payment,
+    args: ConfigArgValues<T>,
+    method: PaymentMethod,
+) => CancelPaymentResult | CancelPaymentErrorResult | Promise<CancelPaymentResult | CancelPaymentErrorResult>;
+
 /**
  * @description
  * This function contains the logic for creating a refund. See {@link PaymentMethodHandler} for an example.
@@ -214,6 +268,17 @@ export interface PaymentMethodConfigOptions<T extends ConfigArgs> extends Config
      * need only return `{ success: true }`.
      */
     settlePayment: SettlePaymentFn<T>;
+    /**
+     * @description
+     * This function provides the logic for cancelling a payment, which would be invoked when a call is
+     * made to the `cancelPayment` mutation in the Admin API. Cancelling a payment can apply
+     * if, for example, you have created a "payment intent" with the payment provider but not yet
+     * completed the payment. It allows the incomplete payment to be cleaned up on the provider's end
+     * if it gets cancelled via Vendure.
+     *
+     * @since 1.7.0
+     */
+    cancelPayment?: CancelPaymentFn<T>;
     /**
      * @description
      * This function provides the logic for refunding a payment created with this
@@ -289,6 +354,7 @@ export interface PaymentMethodConfigOptions<T extends ConfigArgs> extends Config
 export class PaymentMethodHandler<T extends ConfigArgs = ConfigArgs> extends ConfigurableOperationDef<T> {
     private readonly createPaymentFn: CreatePaymentFn<T>;
     private readonly settlePaymentFn: SettlePaymentFn<T>;
+    private readonly cancelPaymentFn?: CancelPaymentFn<T>;
     private readonly createRefundFn?: CreateRefundFn<T>;
     private readonly onTransitionStartFn?: OnTransitionStartFn<PaymentState, PaymentTransitionData>;
 
@@ -296,7 +362,7 @@ export class PaymentMethodHandler<T extends ConfigArgs = ConfigArgs> extends Con
         super(config);
         this.createPaymentFn = config.createPayment;
         this.settlePaymentFn = config.settlePayment;
-        this.settlePaymentFn = config.settlePayment;
+        this.cancelPaymentFn = config.cancelPayment;
         this.createRefundFn = config.createRefund;
         this.onTransitionStartFn = config.onStateTransitionStart;
     }
@@ -337,15 +403,31 @@ export class PaymentMethodHandler<T extends ConfigArgs = ConfigArgs> extends Con
      * @internal
      */
     async settlePayment(
-        ctx: RequestContext, 
-        order: Order, 
-        payment: Payment, 
+        ctx: RequestContext,
+        order: Order,
+        payment: Payment,
         args: ConfigArg[],
         method: PaymentMethod,
     ) {
         return this.settlePaymentFn(ctx, order, payment, this.argsArrayToHash(args), method);
     }
 
+    /**
+     * @description
+     * Called internally to cancel a payment
+     *
+     * @internal
+     */
+    async cancelPayment(
+        ctx: RequestContext,
+        order: Order,
+        payment: Payment,
+        args: ConfigArg[],
+        method: PaymentMethod,
+    ) {
+        return this.cancelPaymentFn?.(ctx, order, payment, this.argsArrayToHash(args), method);
+    }
+
     /**
      * @description
      * Called internally to create a refund

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

@@ -52,6 +52,7 @@
     "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",
+    "CANCEL_PAYMENT_ERROR": "Cancelling the payment failed",
     "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",

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

@@ -15,6 +15,7 @@ import {
     AdjustmentType,
     CancelOrderInput,
     CancelOrderResult,
+    CancelPaymentResult,
     CreateAddressInput,
     DeletionResponse,
     DeletionResult,
@@ -47,6 +48,7 @@ import { EntityNotFoundError, InternalServerError, UserInputError } from '../../
 import {
     AlreadyRefundedError,
     CancelActiveOrderError,
+    CancelPaymentError,
     EmptyOrderLineSelectionError,
     FulfillmentStateTransitionError,
     InsufficientStockOnHandError,
@@ -1138,6 +1140,24 @@ export class OrderService {
         return payment;
     }
 
+    /**
+     * @description
+     * Cancels a payment by invoking the {@link PaymentMethodHandler}'s `cancelPayment()` method (if defined), and transitions the Payment to
+     * the `Cancelled` state.
+     */
+    async cancelPayment(
+        ctx: RequestContext,
+        paymentId: ID,
+    ): Promise<ErrorResultUnion<CancelPaymentResult, Payment>> {
+        const payment = await this.paymentService.cancelPayment(ctx, paymentId);
+        if (!isGraphQlErrorResult(payment)) {
+            if (payment.state !== 'Cancelled') {
+                return new CancelPaymentError(payment.errorMessage || '');
+            }
+        }
+        return payment;
+    }
+
     /**
      * @description
      * Creates a new Fulfillment associated with the given Order and OrderItems.

+ 46 - 11
packages/core/src/service/services/payment.service.ts

@@ -1,5 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import {
+    CancelPaymentResult,
     ManualPaymentInput,
     RefundOrderInput,
     SettlePaymentResult,
@@ -78,19 +79,12 @@ export class PaymentService {
         if (state === 'Settled') {
             return this.settlePayment(ctx, paymentId);
         }
+        if (state === 'Cancelled') {
+            return this.cancelPayment(ctx, paymentId);
+        }
         const payment = await this.findOneOrThrow(ctx, paymentId);
         const fromState = payment.state;
-
-        try {
-            await this.paymentStateMachine.transition(ctx, payment.order, payment, state);
-        } catch (e) {
-            const transitionError = ctx.translate(e.message, { fromState, toState: state });
-            return new PaymentStateTransitionError(transitionError, fromState, state);
-        }
-        await this.connection.getRepository(ctx, Payment).save(payment, { reload: false });
-        this.eventBus.publish(new PaymentStateTransitionEvent(fromState, state, ctx, payment, payment.order));
-
-        return payment;
+        return this.transitionStateAndSave(ctx, payment, fromState, state);
     }
 
     getNextStates(payment: Payment): ReadonlyArray<PaymentState> {
@@ -174,6 +168,47 @@ export class PaymentService {
             toState = settlePaymentResult.state || 'Error';
             payment.errorMessage = settlePaymentResult.errorMessage;
         }
+        return this.transitionStateAndSave(ctx, payment, fromState, toState);
+    }
+
+    async cancelPayment(ctx: RequestContext, paymentId: ID): Promise<PaymentStateTransitionError | Payment> {
+        const payment = await this.connection.getEntityOrThrow(ctx, Payment, paymentId, {
+            relations: ['order'],
+        });
+        const { paymentMethod, handler } = await this.paymentMethodService.getMethodAndOperations(
+            ctx,
+            payment.method,
+        );
+        const cancelPaymentResult = await handler.cancelPayment(
+            ctx,
+            payment.order,
+            payment,
+            paymentMethod.handler.args,
+            paymentMethod,
+        );
+        const fromState = payment.state;
+        let toState: PaymentState;
+        payment.metadata = this.mergePaymentMetadata(payment.metadata, cancelPaymentResult?.metadata);
+        if (cancelPaymentResult == null || cancelPaymentResult.success) {
+            toState = 'Cancelled';
+        } else {
+            toState = cancelPaymentResult.state || 'Error';
+            payment.errorMessage = cancelPaymentResult.errorMessage;
+        }
+        return this.transitionStateAndSave(ctx, payment, fromState, toState);
+    }
+
+    private async transitionStateAndSave(
+        ctx: RequestContext,
+        payment: Payment,
+        fromState: PaymentState,
+        toState: PaymentState,
+    ) {
+        if (fromState === toState) {
+            // in case metadata was changed
+            await this.connection.getRepository(ctx, Payment).save(payment, { reload: false });
+            return payment;
+        }
         try {
             await this.paymentStateMachine.transition(ctx, payment.order, payment, toState);
         } catch (e) {

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

@@ -309,6 +309,15 @@ export type CancelOrderResult =
     | CancelActiveOrderError
     | OrderStateTransitionError;
 
+/** Returned if the Payment cancellation fails */
+export type CancelPaymentError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    paymentErrorMessage: Scalars['String'];
+};
+
+export type CancelPaymentResult = Payment | CancelPaymentError | PaymentStateTransitionError;
+
 export type Cancellation = Node &
     StockMovement & {
         id: Scalars['ID'];
@@ -1383,6 +1392,7 @@ export enum ErrorCode {
     LANGUAGE_NOT_AVAILABLE_ERROR = 'LANGUAGE_NOT_AVAILABLE_ERROR',
     CHANNEL_DEFAULT_LANGUAGE_ERROR = 'CHANNEL_DEFAULT_LANGUAGE_ERROR',
     SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
+    CANCEL_PAYMENT_ERROR = 'CANCEL_PAYMENT_ERROR',
     EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
     ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
     INVALID_FULFILLMENT_HANDLER_ERROR = 'INVALID_FULFILLMENT_HANDLER_ERROR',
@@ -2341,6 +2351,7 @@ export type Mutation = {
     cancelJob: Job;
     flushBufferedJobs: Success;
     settlePayment: SettlePaymentResult;
+    cancelPayment: CancelPaymentResult;
     addFulfillmentToOrder: AddFulfillmentToOrderResult;
     cancelOrder: CancelOrderResult;
     refundOrder: RefundOrderResult;
@@ -2661,6 +2672,10 @@ export type MutationSettlePaymentArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationCancelPaymentArgs = {
+    id: Scalars['ID'];
+};
+
 export type MutationAddFulfillmentToOrderArgs = {
     input: FulfillOrderInput;
 };

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

@@ -309,6 +309,15 @@ export type CancelOrderResult =
     | CancelActiveOrderError
     | OrderStateTransitionError;
 
+/** Returned if the Payment cancellation fails */
+export type CancelPaymentError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    paymentErrorMessage: Scalars['String'];
+};
+
+export type CancelPaymentResult = Payment | CancelPaymentError | PaymentStateTransitionError;
+
 export type Cancellation = Node &
     StockMovement & {
         id: Scalars['ID'];
@@ -1383,6 +1392,7 @@ export enum ErrorCode {
     LANGUAGE_NOT_AVAILABLE_ERROR = 'LANGUAGE_NOT_AVAILABLE_ERROR',
     CHANNEL_DEFAULT_LANGUAGE_ERROR = 'CHANNEL_DEFAULT_LANGUAGE_ERROR',
     SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
+    CANCEL_PAYMENT_ERROR = 'CANCEL_PAYMENT_ERROR',
     EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
     ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
     INVALID_FULFILLMENT_HANDLER_ERROR = 'INVALID_FULFILLMENT_HANDLER_ERROR',
@@ -2341,6 +2351,7 @@ export type Mutation = {
     cancelJob: Job;
     flushBufferedJobs: Success;
     settlePayment: SettlePaymentResult;
+    cancelPayment: CancelPaymentResult;
     addFulfillmentToOrder: AddFulfillmentToOrderResult;
     cancelOrder: CancelOrderResult;
     refundOrder: RefundOrderResult;
@@ -2661,6 +2672,10 @@ export type MutationSettlePaymentArgs = {
     id: Scalars['ID'];
 };
 
+export type MutationCancelPaymentArgs = {
+    id: Scalars['ID'];
+};
+
 export type MutationAddFulfillmentToOrderArgs = {
     input: FulfillOrderInput;
 };

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema-admin.json


Некоторые файлы не были показаны из-за большого количества измененных файлов