Browse Source

feat(core): Support custom Payment process

Relates to #359, relates to #507
Michael Bromley 5 years ago
parent
commit
d3b0f606ed
27 changed files with 857 additions and 307 deletions
  1. 17 17
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 1 1
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  3. 16 16
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  4. 2 2
      packages/common/src/generated-shop-types.ts
  5. 17 17
      packages/common/src/generated-types.ts
  6. 20 11
      packages/core/e2e/graphql/fragments.ts
  7. 73 32
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  8. 14 2
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  9. 2 5
      packages/core/e2e/graphql/shared-definitions.ts
  10. 9 0
      packages/core/e2e/graphql/shop-definitions.ts
  11. 9 6
      packages/core/e2e/order-channel.e2e-spec.ts
  12. 410 0
      packages/core/e2e/payment-process.e2e-spec.ts
  13. 2 1
      packages/core/src/api/resolvers/admin/order.resolver.ts
  14. 4 0
      packages/core/src/api/schema/admin-api/order-admin.type.graphql
  15. 3 3
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  16. 9 2
      packages/core/src/service/helpers/order-state-machine/order-state.ts
  17. 8 5
      packages/core/src/service/helpers/payment-state-machine/payment-state.ts
  18. 3 1
      packages/core/src/service/helpers/utils/order-utils.ts
  19. 26 42
      packages/core/src/service/services/order.service.ts
  20. 1 125
      packages/core/src/service/services/payment-method.service.ts
  21. 194 2
      packages/core/src/service/services/payment.service.ts
  22. 1 1
      packages/dev-server/.gitignore
  23. 0 0
      packages/dev-server/vendure.sqlite-shm
  24. 0 0
      packages/dev-server/vendure.sqlite-wal
  25. 16 16
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  26. 0 0
      schema-admin.json
  27. 0 0
      schema-shop.json

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

@@ -1609,6 +1609,21 @@ export type Fulfillment = Node & {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type Payment = Node & {
+  __typename?: 'Payment';
+  nextStates: Array<Scalars['String']>;
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  method: Scalars['String'];
+  amount: Scalars['Int'];
+  state: Scalars['String'];
+  transactionId?: Maybe<Scalars['String']>;
+  errorMessage?: Maybe<Scalars['String']>;
+  refunds: Array<Refund>;
+  metadata?: Maybe<Scalars['JSON']>;
+};
+
 export type OrderModification = Node & {
   __typename?: 'OrderModification';
   id: Scalars['ID'];
@@ -1971,21 +1986,6 @@ export type PaymentMethod = Node & {
   handler: ConfigurableOperation;
 };
 
-export type Payment = Node & {
-  __typename?: 'Payment';
-  nextStates: Array<Scalars['String']>;
-  id: Scalars['ID'];
-  createdAt: Scalars['DateTime'];
-  updatedAt: Scalars['DateTime'];
-  method: Scalars['String'];
-  amount: Scalars['Int'];
-  state: Scalars['String'];
-  transactionId?: Maybe<Scalars['String']>;
-  errorMessage?: Maybe<Scalars['String']>;
-  refunds: Array<Refund>;
-  metadata?: Maybe<Scalars['JSON']>;
-};
-
 export type Product = Node & {
   __typename?: 'Product';
   enabled: Scalars['Boolean'];
@@ -3950,9 +3950,9 @@ export type OrderLine = Node & {
   unitPrice: Scalars['Int'];
   /** The price of a single unit, including tax but excluding discounts */
   unitPriceWithTax: Scalars['Int'];
-  /** If the unitPrice has changed since initially added to Order */
+  /** Non-zero if the unitPrice has changed since it was initially added to Order */
   unitPriceChangeSinceAdded: Scalars['Int'];
-  /** If the unitPriceWithTax has changed since initially added to Order */
+  /** Non-zero if the unitPriceWithTax has changed since it was initially added to Order */
   unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
   /**
    * The price of a single unit including discounts, excluding tax.

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

@@ -99,9 +99,9 @@ const result: PossibleTypesResultData = {
             'Job',
             'Order',
             'Fulfillment',
+            'Payment',
             'OrderModification',
             'PaymentMethod',
-            'Payment',
             'Product',
             'ProductVariant',
             'StockAdjustment',

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

@@ -1402,6 +1402,20 @@ export type Fulfillment = Node & {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type Payment = Node & {
+    nextStates: Array<Scalars['String']>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    method: Scalars['String'];
+    amount: Scalars['Int'];
+    state: Scalars['String'];
+    transactionId?: Maybe<Scalars['String']>;
+    errorMessage?: Maybe<Scalars['String']>;
+    refunds: Array<Refund>;
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
 export type OrderModification = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -1774,20 +1788,6 @@ export type PaymentMethod = Node & {
     handler: ConfigurableOperation;
 };
 
-export type Payment = Node & {
-    nextStates: Array<Scalars['String']>;
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    method: Scalars['String'];
-    amount: Scalars['Int'];
-    state: Scalars['String'];
-    transactionId?: Maybe<Scalars['String']>;
-    errorMessage?: Maybe<Scalars['String']>;
-    refunds: Array<Refund>;
-    metadata?: Maybe<Scalars['JSON']>;
-};
-
 export type Product = Node & {
     enabled: Scalars['Boolean'];
     channels: Array<Channel>;
@@ -3693,9 +3693,9 @@ export type OrderLine = Node & {
     unitPrice: Scalars['Int'];
     /** The price of a single unit, including tax but excluding discounts */
     unitPriceWithTax: Scalars['Int'];
-    /** If the unitPrice has changed since initially added to Order */
+    /** Non-zero if the unitPrice has changed since it was initially added to Order */
     unitPriceChangeSinceAdded: Scalars['Int'];
-    /** If the unitPriceWithTax has changed since initially added to Order */
+    /** Non-zero if the unitPriceWithTax has changed since it was initially added to Order */
     unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
     /**
      * The price of a single unit including discounts, excluding tax.

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

@@ -1908,9 +1908,9 @@ export type OrderLine = Node & {
     unitPrice: Scalars['Int'];
     /** The price of a single unit, including tax but excluding discounts */
     unitPriceWithTax: Scalars['Int'];
-    /** If the unitPrice has changed since initially added to Order */
+    /** Non-zero if the unitPrice has changed since it was initially added to Order */
     unitPriceChangeSinceAdded: Scalars['Int'];
-    /** If the unitPriceWithTax has changed since initially added to Order */
+    /** Non-zero if the unitPriceWithTax has changed since it was initially added to Order */
     unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
     /**
      * The price of a single unit including discounts, excluding tax.

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

@@ -1572,6 +1572,21 @@ export type Fulfillment = Node & {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type Payment = Node & {
+  __typename?: 'Payment';
+  nextStates: Array<Scalars['String']>;
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  method: Scalars['String'];
+  amount: Scalars['Int'];
+  state: Scalars['String'];
+  transactionId?: Maybe<Scalars['String']>;
+  errorMessage?: Maybe<Scalars['String']>;
+  refunds: Array<Refund>;
+  metadata?: Maybe<Scalars['JSON']>;
+};
+
 export type OrderModification = Node & {
   __typename?: 'OrderModification';
   id: Scalars['ID'];
@@ -1934,21 +1949,6 @@ export type PaymentMethod = Node & {
   handler: ConfigurableOperation;
 };
 
-export type Payment = Node & {
-  __typename?: 'Payment';
-  nextStates: Array<Scalars['String']>;
-  id: Scalars['ID'];
-  createdAt: Scalars['DateTime'];
-  updatedAt: Scalars['DateTime'];
-  method: Scalars['String'];
-  amount: Scalars['Int'];
-  state: Scalars['String'];
-  transactionId?: Maybe<Scalars['String']>;
-  errorMessage?: Maybe<Scalars['String']>;
-  refunds: Array<Refund>;
-  metadata?: Maybe<Scalars['JSON']>;
-};
-
 export type Product = Node & {
   __typename?: 'Product';
   enabled: Scalars['Boolean'];
@@ -3912,9 +3912,9 @@ export type OrderLine = Node & {
   unitPrice: Scalars['Int'];
   /** The price of a single unit, including tax but excluding discounts */
   unitPriceWithTax: Scalars['Int'];
-  /** If the unitPrice has changed since initially added to Order */
+  /** Non-zero if the unitPrice has changed since it was initially added to Order */
   unitPriceChangeSinceAdded: Scalars['Int'];
-  /** If the unitPriceWithTax has changed since initially added to Order */
+  /** Non-zero if the unitPriceWithTax has changed since it was initially added to Order */
   unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
   /**
    * The price of a single unit including discounts, excluding tax.

+ 20 - 11
packages/core/e2e/graphql/fragments.ts

@@ -317,6 +317,7 @@ export const ORDER_FRAGMENT = gql`
         createdAt
         updatedAt
         code
+        active
         state
         total
         totalWithTax
@@ -343,6 +344,23 @@ export const ORDER_ITEM_FRAGMENT = gql`
     }
 `;
 
+export const PAYMENT_FRAGMENT = gql`
+    fragment Payment on Payment {
+        id
+        transactionId
+        amount
+        method
+        state
+        nextStates
+        metadata
+        refunds {
+            id
+            total
+            reason
+        }
+    }
+`;
+
 export const ORDER_WITH_LINES_FRAGMENT = gql`
     fragment OrderWithLines on Order {
         id
@@ -400,22 +418,13 @@ export const ORDER_WITH_LINES_FRAGMENT = gql`
             ...ShippingAddress
         }
         payments {
-            id
-            transactionId
-            amount
-            method
-            state
-            metadata
-            refunds {
-                id
-                total
-                reason
-            }
+            ...Payment
         }
         total
     }
     ${SHIPPING_ADDRESS_FRAGMENT}
     ${ORDER_ITEM_FRAGMENT}
+    ${PAYMENT_FRAGMENT}
 `;
 
 export const PROMOTION_FRAGMENT = gql`

+ 73 - 32
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1402,6 +1402,20 @@ export type Fulfillment = Node & {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type Payment = Node & {
+    nextStates: Array<Scalars['String']>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    method: Scalars['String'];
+    amount: Scalars['Int'];
+    state: Scalars['String'];
+    transactionId?: Maybe<Scalars['String']>;
+    errorMessage?: Maybe<Scalars['String']>;
+    refunds: Array<Refund>;
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
 export type OrderModification = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -1774,20 +1788,6 @@ export type PaymentMethod = Node & {
     handler: ConfigurableOperation;
 };
 
-export type Payment = Node & {
-    nextStates: Array<Scalars['String']>;
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    method: Scalars['String'];
-    amount: Scalars['Int'];
-    state: Scalars['String'];
-    transactionId?: Maybe<Scalars['String']>;
-    errorMessage?: Maybe<Scalars['String']>;
-    refunds: Array<Refund>;
-    metadata?: Maybe<Scalars['JSON']>;
-};
-
 export type Product = Node & {
     enabled: Scalars['Boolean'];
     channels: Array<Channel>;
@@ -3693,9 +3693,9 @@ export type OrderLine = Node & {
     unitPrice: Scalars['Int'];
     /** The price of a single unit, including tax but excluding discounts */
     unitPriceWithTax: Scalars['Int'];
-    /** If the unitPrice has changed since initially added to Order */
+    /** Non-zero if the unitPrice has changed since it was initially added to Order */
     unitPriceChangeSinceAdded: Scalars['Int'];
-    /** If the unitPriceWithTax has changed since initially added to Order */
+    /** Non-zero if the unitPriceWithTax has changed since it was initially added to Order */
     unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
     /**
      * The price of a single unit including discounts, excluding tax.
@@ -5144,6 +5144,7 @@ export type OrderFragment = Pick<
     | 'createdAt'
     | 'updatedAt'
     | 'code'
+    | 'active'
     | 'state'
     | 'total'
     | 'totalWithTax'
@@ -5156,6 +5157,11 @@ export type OrderItemFragment = Pick<
     'id' | 'cancelled' | 'unitPrice' | 'unitPriceWithTax' | 'taxRate'
 > & { fulfillment?: Maybe<Pick<Fulfillment, 'id'>> };
 
+export type PaymentFragment = Pick<
+    Payment,
+    'id' | 'transactionId' | 'amount' | 'method' | 'state' | 'nextStates' | 'metadata'
+> & { refunds: Array<Pick<Refund, 'id' | 'total' | 'reason'>> };
+
 export type OrderWithLinesFragment = Pick<
     Order,
     | 'id'
@@ -5184,13 +5190,7 @@ export type OrderWithLinesFragment = Pick<
     surcharges: Array<Pick<Surcharge, 'id' | 'description' | 'sku' | 'price' | 'priceWithTax'>>;
     shippingLines: Array<{ shippingMethod: Pick<ShippingMethod, 'id' | 'code' | 'description'> }>;
     shippingAddress?: Maybe<ShippingAddressFragment>;
-    payments?: Maybe<
-        Array<
-            Pick<Payment, 'id' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'> & {
-                refunds: Array<Pick<Refund, 'id' | 'total' | 'reason'>>;
-            }
-        >
-    >;
+    payments?: Maybe<Array<PaymentFragment>>;
 };
 
 export type PromotionFragment = Pick<
@@ -5847,8 +5847,6 @@ export type SettlePaymentMutation = {
         | Pick<OrderStateTransitionError, 'errorCode' | 'message'>;
 };
 
-export type PaymentFragment = Pick<Payment, 'id' | 'state' | 'metadata'>;
-
 export type GetOrderHistoryQueryVariables = Exact<{
     id: Scalars['ID'];
     options?: Maybe<HistoryEntryListOptions>;
@@ -6101,6 +6099,25 @@ export type GetPaymentMethodQueryVariables = Exact<{
 
 export type GetPaymentMethodQuery = { paymentMethod?: Maybe<PaymentMethodFragment> };
 
+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;
+}>;
+
+export type AddManualPayment2Mutation = {
+    addManualPaymentToOrder: OrderWithLinesFragment | Pick<ManualPaymentStateError, 'errorCode' | 'message'>;
+};
+
 export type UpdateProductOptionGroupMutationVariables = Exact<{
     input: UpdateProductOptionGroupInput;
 }>;
@@ -7268,6 +7285,11 @@ export namespace OrderItem {
     export type Fulfillment = NonNullable<OrderItemFragment['fulfillment']>;
 }
 
+export namespace Payment {
+    export type Fragment = PaymentFragment;
+    export type Refunds = NonNullable<NonNullable<PaymentFragment['refunds']>[number]>;
+}
+
 export namespace OrderWithLines {
     export type Fragment = OrderWithLinesFragment;
     export type Customer = NonNullable<OrderWithLinesFragment['customer']>;
@@ -7288,9 +7310,6 @@ export namespace OrderWithLines {
     >;
     export type ShippingAddress = NonNullable<OrderWithLinesFragment['shippingAddress']>;
     export type Payments = NonNullable<NonNullable<OrderWithLinesFragment['payments']>[number]>;
-    export type Refunds = NonNullable<
-        NonNullable<NonNullable<NonNullable<OrderWithLinesFragment['payments']>[number]>['refunds']>[number]
-    >;
 }
 
 export namespace Promotion {
@@ -7971,10 +7990,6 @@ export namespace SettlePayment {
     >;
 }
 
-export namespace Payment {
-    export type Fragment = PaymentFragment;
-}
-
 export namespace GetOrderHistory {
     export type Variables = GetOrderHistoryQueryVariables;
     export type Query = GetOrderHistoryQuery;
@@ -8244,6 +8259,32 @@ export namespace GetPaymentMethod {
     export type PaymentMethod = NonNullable<GetPaymentMethodQuery['paymentMethod']>;
 }
 
+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;
+    export type AddManualPaymentToOrder = NonNullable<AddManualPayment2Mutation['addManualPaymentToOrder']>;
+    export type ErrorResultInlineFragment = DiscriminateUnion<
+        NonNullable<AddManualPayment2Mutation['addManualPaymentToOrder']>,
+        { __typename?: 'ErrorResult' }
+    >;
+}
+
 export namespace UpdateProductOptionGroup {
     export type Variables = UpdateProductOptionGroupMutationVariables;
     export type Mutation = UpdateProductOptionGroupMutation;

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

@@ -1848,9 +1848,9 @@ export type OrderLine = Node & {
     unitPrice: Scalars['Int'];
     /** The price of a single unit, including tax but excluding discounts */
     unitPriceWithTax: Scalars['Int'];
-    /** If the unitPrice has changed since initially added to Order */
+    /** Non-zero if the unitPrice has changed since it was initially added to Order */
     unitPriceChangeSinceAdded: Scalars['Int'];
-    /** If the unitPriceWithTax has changed since initially added to Order */
+    /** Non-zero if the unitPriceWithTax has changed since it was initially added to Order */
     unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
     /**
      * The price of a single unit including discounts, excluding tax.
@@ -3008,6 +3008,12 @@ export type GetOrderByCodeQueryVariables = Exact<{
 
 export type GetOrderByCodeQuery = { orderByCode?: Maybe<TestOrderFragmentFragment> };
 
+export type GetOrderShopQueryVariables = Exact<{
+    id: Scalars['ID'];
+}>;
+
+export type GetOrderShopQuery = { order?: Maybe<TestOrderFragmentFragment> };
+
 export type GetOrderPromotionsByCodeQueryVariables = Exact<{
     code: Scalars['String'];
 }>;
@@ -3515,6 +3521,12 @@ export namespace GetOrderByCode {
     export type OrderByCode = NonNullable<GetOrderByCodeQuery['orderByCode']>;
 }
 
+export namespace GetOrderShop {
+    export type Variables = GetOrderShopQueryVariables;
+    export type Query = GetOrderShopQuery;
+    export type Order = NonNullable<GetOrderShopQuery['order']>;
+}
+
 export namespace GetOrderPromotionsByCode {
     export type Variables = GetOrderPromotionsByCodeQueryVariables;
     export type Query = GetOrderPromotionsByCodeQuery;

+ 2 - 5
packages/core/e2e/graphql/shared-definitions.ts

@@ -14,6 +14,7 @@ import {
     GLOBAL_SETTINGS_FRAGMENT,
     ORDER_FRAGMENT,
     ORDER_WITH_LINES_FRAGMENT,
+    PAYMENT_FRAGMENT,
     PRODUCT_OPTION_GROUP_FRAGMENT,
     PRODUCT_VARIANT_FRAGMENT,
     PRODUCT_WITH_OPTIONS_FRAGMENT,
@@ -792,11 +793,7 @@ export const SETTLE_PAYMENT = gql`
             }
         }
     }
-    fragment Payment on Payment {
-        id
-        state
-        metadata
-    }
+    ${PAYMENT_FRAGMENT}
 `;
 
 export const GET_ORDER_HISTORY = gql`

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

@@ -445,6 +445,15 @@ export const GET_ORDER_BY_CODE = gql`
     ${TEST_ORDER_FRAGMENT}
 `;
 
+export const GET_ORDER_SHOP = gql`
+    query GetOrderShop($id: ID!) {
+        order(id: $id) {
+            ...TestOrderFragment
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;
+
 export const GET_ORDER_PROMOTIONS_BY_CODE = gql`
     query GetOrderPromotionsByCode($code: String!) {
         orderByCode(code: $code) {

+ 9 - 6
packages/core/e2e/order-channel.e2e-spec.ts

@@ -15,21 +15,24 @@ import {
     CreateChannel,
     CurrencyCode,
     GetCustomerList,
-    GetOrder,
     GetOrderList,
     GetProductWithVariants,
     LanguageCode,
 } from './graphql/generated-e2e-admin-types';
-import { AddItemToOrder, GetActiveOrder, UpdatedOrderFragment } from './graphql/generated-e2e-shop-types';
+import {
+    AddItemToOrder,
+    GetActiveOrder,
+    GetOrderShop,
+    UpdatedOrderFragment,
+} from './graphql/generated-e2e-shop-types';
 import {
     ASSIGN_PRODUCT_TO_CHANNEL,
     CREATE_CHANNEL,
     GET_CUSTOMER_LIST,
-    GET_ORDER,
     GET_ORDERS_LIST,
     GET_PRODUCT_WITH_VARIANTS,
 } from './graphql/shared-definitions';
-import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER } from './graphql/shop-definitions';
+import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER, GET_ORDER_SHOP } from './graphql/shop-definitions';
 
 describe('Channelaware orders', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
@@ -177,14 +180,14 @@ describe('Channelaware orders', () => {
     });
 
     it('returns null when requesting order from other channel', async () => {
-        const result = await shopClient.query<GetOrder.Query>(GET_ORDER, {
+        const result = await shopClient.query<GetOrderShop.Query>(GET_ORDER_SHOP, {
             id: order2Id,
         });
         expect(result!.order).toBeNull();
     });
 
     it('returns order when requesting order from correct channel', async () => {
-        const result = await shopClient.query<GetOrder.Query>(GET_ORDER, {
+        const result = await shopClient.query<GetOrderShop.Query>(GET_ORDER_SHOP, {
             id: order1Id,
         });
         expect(result.order!.id).toBe(order1Id);

+ 410 - 0
packages/core/e2e/payment-process.e2e-spec.ts

@@ -0,0 +1,410 @@
+/* tslint:disable:no-non-null-assertion */
+import {
+    CustomOrderProcess,
+    CustomPaymentProcess,
+    DefaultLogger,
+    LanguageCode,
+    mergeConfig,
+    Order,
+    OrderPlacedStrategy,
+    OrderState,
+    PaymentMethodHandler,
+    RequestContext,
+} from '@vendure/core';
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+
+import { ORDER_WITH_LINES_FRAGMENT, PAYMENT_FRAGMENT } from './graphql/fragments';
+import {
+    AddManualPayment2,
+    AdminTransition,
+    ErrorCode,
+    GetOrder,
+    OrderFragment,
+    PaymentFragment,
+    TransitionPaymentToState,
+} from './graphql/generated-e2e-admin-types';
+import {
+    AddItemToOrder,
+    AddPaymentToOrder,
+    GetActiveOrder,
+    TestOrderFragmentFragment,
+} from './graphql/generated-e2e-shop-types';
+import { ADMIN_TRANSITION_TO_STATE, GET_ORDER } 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';
+
+const initSpy = jest.fn();
+const transitionStartSpy = jest.fn();
+const transitionEndSpy = jest.fn();
+const transitionErrorSpy = jest.fn();
+const settlePaymentSpy = jest.fn();
+
+describe('Payment process', () => {
+    let orderId: string;
+    let payment1Id: string;
+
+    const PAYMENT_ERROR_MESSAGE = 'Payment is not valid';
+    const customPaymentProcess: CustomPaymentProcess<'Validating'> = {
+        init(injector) {
+            initSpy(injector.getConnection().name);
+        },
+        transitions: {
+            Created: {
+                to: ['Validating'],
+                mergeStrategy: 'merge',
+            },
+            Validating: {
+                to: ['Settled', 'Declined', 'Cancelled'],
+            },
+        },
+        onTransitionStart(fromState, toState, data) {
+            transitionStartSpy(fromState, toState, data);
+            if (fromState === 'Validating' && toState === 'Settled') {
+                if (!data.payment.metadata.valid) {
+                    return PAYMENT_ERROR_MESSAGE;
+                }
+            }
+        },
+        onTransitionEnd(fromState, toState, data) {
+            transitionEndSpy(fromState, toState, data);
+        },
+        onTransitionError(fromState, toState, message) {
+            transitionErrorSpy(fromState, toState, message);
+        },
+    };
+
+    const customOrderProcess: CustomOrderProcess<'ValidatingPayment'> = {
+        transitions: {
+            ArrangingPayment: {
+                to: ['ValidatingPayment'],
+                mergeStrategy: 'replace',
+            },
+            ValidatingPayment: {
+                to: ['PaymentAuthorized', 'PaymentSettled', 'ArrangingAdditionalPayment'],
+            },
+        },
+    };
+
+    const testPaymentHandler = new PaymentMethodHandler({
+        code: 'test-handler',
+        description: [{ languageCode: LanguageCode.en, value: 'Test handler' }],
+        args: {},
+        createPayment: (ctx, order, amount, args, metadata) => {
+            return {
+                state: 'Validating' as any,
+                amount,
+                metadata,
+            };
+        },
+        settlePayment: (ctx, order, payment) => {
+            settlePaymentSpy();
+            return {
+                success: true,
+            };
+        },
+    });
+
+    class TestOrderPlacedStrategy implements OrderPlacedStrategy {
+        shouldSetAsPlaced(
+            ctx: RequestContext,
+            fromState: OrderState,
+            toState: OrderState,
+            order: Order,
+        ): boolean | Promise<boolean> {
+            return fromState === 'ArrangingPayment' && toState === ('ValidatingPayment' as any);
+        }
+    }
+
+    const orderGuard: ErrorResultGuard<TestOrderFragmentFragment | OrderFragment> = createErrorResultGuard(
+        input => !!input.total,
+    );
+
+    const paymentGuard: ErrorResultGuard<PaymentFragment> = createErrorResultGuard(input => !!input.id);
+
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            logger: new DefaultLogger(),
+            orderOptions: {
+                process: [customOrderProcess as any],
+                orderPlacedStrategy: new TestOrderPlacedStrategy(),
+            },
+            paymentOptions: {
+                paymentMethodHandlers: [testPaymentHandler],
+                customPaymentProcess: [customPaymentProcess as any],
+            },
+        }),
+    );
+
+    beforeAll(async () => {
+        await server.init({
+            initialData: {
+                ...initialData,
+                paymentMethods: [
+                    {
+                        name: testPaymentHandler.code,
+                        handler: { code: testPaymentHandler.code, arguments: [] },
+                    },
+                ],
+            },
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+
+        await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_1',
+            quantity: 1,
+        });
+        orderId = (await proceedToArrangingPayment(shopClient)) as string;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('CustomPaymentProcess is injectable', () => {
+        expect(initSpy).toHaveBeenCalled();
+        expect(initSpy.mock.calls[0][0]).toBe('default');
+    });
+
+    it('creates Payment in custom state', async () => {
+        const { addPaymentToOrder } = await shopClient.query<
+            AddPaymentToOrder.Mutation,
+            AddPaymentToOrder.Variables
+        >(ADD_PAYMENT, {
+            input: {
+                method: testPaymentHandler.code,
+                metadata: {
+                    valid: true,
+                },
+            },
+        });
+
+        orderGuard.assertSuccess(addPaymentToOrder);
+
+        const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+            id: orderId,
+        });
+
+        expect(order?.state).toBe('ArrangingPayment');
+        expect(order?.payments?.length).toBe(1);
+        expect(order?.payments?.[0].state).toBe('Validating');
+        payment1Id = addPaymentToOrder?.payments?.[0].id!;
+    });
+
+    it('calls transition hooks', async () => {
+        expect(transitionStartSpy.mock.calls[0].slice(0, 2)).toEqual(['Created', 'Validating']);
+        expect(transitionEndSpy.mock.calls[0].slice(0, 2)).toEqual(['Created', 'Validating']);
+        expect(transitionErrorSpy).not.toHaveBeenCalled();
+    });
+
+    it('Payment next states', async () => {
+        const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+            id: orderId,
+        });
+        expect(order?.payments?.[0].nextStates).toEqual(['Settled', 'Declined', 'Cancelled']);
+    });
+
+    it('transition Order to custom state, custom OrderPlacedStrategy sets as placed', async () => {
+        const { activeOrder: activeOrderPre } = await shopClient.query<GetActiveOrder.Query>(
+            GET_ACTIVE_ORDER,
+        );
+        expect(activeOrderPre).not.toBeNull();
+
+        const { transitionOrderToState } = await adminClient.query<
+            AdminTransition.Mutation,
+            AdminTransition.Variables
+        >(ADMIN_TRANSITION_TO_STATE, {
+            id: orderId,
+            state: 'ValidatingPayment',
+        });
+
+        orderGuard.assertSuccess(transitionOrderToState);
+
+        expect(transitionOrderToState.state).toBe('ValidatingPayment');
+        expect(transitionOrderToState?.active).toBe(false);
+
+        const { activeOrder: activeOrderPost } = await shopClient.query<GetActiveOrder.Query>(
+            GET_ACTIVE_ORDER,
+        );
+        expect(activeOrderPost).toBeNull();
+    });
+
+    it('transitionPaymentToState succeeds', async () => {
+        const { transitionPaymentToState } = await adminClient.query<
+            TransitionPaymentToState.Mutation,
+            TransitionPaymentToState.Variables
+        >(TRANSITION_PAYMENT_TO_STATE, {
+            id: payment1Id,
+            state: 'Settled',
+        });
+
+        paymentGuard.assertSuccess(transitionPaymentToState);
+        expect(transitionPaymentToState.state).toBe('Settled');
+
+        const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+            id: orderId,
+        });
+        expect(order?.state).toBe('PaymentSettled');
+        expect(settlePaymentSpy).toHaveBeenCalled();
+    });
+
+    describe('failing, cancelling, and manually adding a Payment', () => {
+        let order2Id: string;
+        let payment2Id: string;
+
+        beforeAll(async () => {
+            await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+            await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_1',
+                quantity: 1,
+            });
+            order2Id = (await proceedToArrangingPayment(shopClient)) as string;
+            const { addPaymentToOrder } = await shopClient.query<
+                AddPaymentToOrder.Mutation,
+                AddPaymentToOrder.Variables
+            >(ADD_PAYMENT, {
+                input: {
+                    method: testPaymentHandler.code,
+                    metadata: {
+                        valid: false,
+                    },
+                },
+            });
+
+            orderGuard.assertSuccess(addPaymentToOrder);
+            payment2Id = addPaymentToOrder!.payments![0].id;
+
+            await adminClient.query<AdminTransition.Mutation, AdminTransition.Variables>(
+                ADMIN_TRANSITION_TO_STATE,
+                {
+                    id: order2Id,
+                    state: 'ValidatingPayment',
+                },
+            );
+        });
+
+        it('attempting to transition payment to settled fails', async () => {
+            const { transitionPaymentToState } = await adminClient.query<
+                TransitionPaymentToState.Mutation,
+                TransitionPaymentToState.Variables
+            >(TRANSITION_PAYMENT_TO_STATE, {
+                id: payment2Id,
+                state: 'Settled',
+            });
+
+            paymentGuard.assertErrorResult(transitionPaymentToState);
+            expect(transitionPaymentToState.errorCode).toBe(ErrorCode.PAYMENT_STATE_TRANSITION_ERROR);
+            expect((transitionPaymentToState as any).transitionError).toBe(PAYMENT_ERROR_MESSAGE);
+
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: order2Id,
+            });
+            expect(order?.state).toBe('ValidatingPayment');
+        });
+
+        it('cancel failed payment', async () => {
+            const { transitionPaymentToState } = await adminClient.query<
+                TransitionPaymentToState.Mutation,
+                TransitionPaymentToState.Variables
+            >(TRANSITION_PAYMENT_TO_STATE, {
+                id: payment2Id,
+                state: 'Cancelled',
+            });
+
+            paymentGuard.assertSuccess(transitionPaymentToState);
+            expect(transitionPaymentToState.state).toBe('Cancelled');
+
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: order2Id,
+            });
+            expect(order?.state).toBe('ValidatingPayment');
+        });
+
+        it('manually adds payment', async () => {
+            const { transitionOrderToState } = await adminClient.query<
+                AdminTransition.Mutation,
+                AdminTransition.Variables
+            >(ADMIN_TRANSITION_TO_STATE, {
+                id: order2Id,
+                state: 'ArrangingAdditionalPayment',
+            });
+
+            orderGuard.assertSuccess(transitionOrderToState);
+
+            const { addManualPaymentToOrder } = await adminClient.query<
+                AddManualPayment2.Mutation,
+                AddManualPayment2.Variables
+            >(ADD_MANUAL_PAYMENT, {
+                input: {
+                    orderId: order2Id,
+                    metadata: {},
+                    method: 'manual payment',
+                    transactionId: '12345',
+                },
+            });
+
+            orderGuard.assertSuccess(addManualPaymentToOrder);
+            expect(addManualPaymentToOrder.state).toBe('ArrangingAdditionalPayment');
+            expect(addManualPaymentToOrder.payments![1].state).toBe('Settled');
+            expect(addManualPaymentToOrder.payments![1].amount).toBe(addManualPaymentToOrder.totalWithTax);
+        });
+
+        it('transitions Order to PaymentSettled', async () => {
+            const { transitionOrderToState } = await adminClient.query<
+                AdminTransition.Mutation,
+                AdminTransition.Variables
+            >(ADMIN_TRANSITION_TO_STATE, {
+                id: order2Id,
+                state: 'PaymentSettled',
+            });
+
+            orderGuard.assertSuccess(transitionOrderToState);
+            expect(transitionOrderToState.state).toBe('PaymentSettled');
+
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: order2Id,
+            });
+            const settledPaymentAmount = order?.payments
+                ?.filter(p => p.state === 'Settled')
+                .reduce((sum, p) => sum + p.amount, 0);
+
+            expect(settledPaymentAmount).toBe(order?.totalWithTax);
+        });
+    });
+});
+
+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) {
+            ...OrderWithLines
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+    ${ORDER_WITH_LINES_FRAGMENT}
+`;

+ 2 - 1
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -21,6 +21,7 @@ import {
     QueryOrdersArgs,
     RefundOrderResult,
     SettlePaymentResult,
+    TransitionPaymentToStateResult,
 } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
@@ -156,7 +157,7 @@ export class OrderResolver {
     async transitionPaymentToState(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationTransitionPaymentToStateArgs,
-    ) {
+    ): Promise<ErrorResultUnion<TransitionPaymentToStateResult, Payment>> {
         return this.orderService.transitionPaymentToState(ctx, args.id, args.state as PaymentState);
     }
 

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

@@ -7,6 +7,10 @@ type Fulfillment {
     nextStates: [String!]!
 }
 
+type Payment {
+    nextStates: [String!]!
+}
+
 type OrderModification implements Node {
     id: ID!
     createdAt: DateTime!

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

@@ -28,7 +28,7 @@ import { Promotion } from '../../../entity/promotion/promotion.entity';
 import { ShippingLine } from '../../../entity/shipping-line/shipping-line.entity';
 import { Surcharge } from '../../../entity/surcharge/surcharge.entity';
 import { CountryService } from '../../services/country.service';
-import { PaymentMethodService } from '../../services/payment-method.service';
+import { PaymentService } from '../../services/payment.service';
 import { ProductVariantService } from '../../services/product-variant.service';
 import { StockMovementService } from '../../services/stock-movement.service';
 import { TransactionalConnection } from '../../transaction/transactional-connection';
@@ -53,7 +53,7 @@ export class OrderModifier {
         private connection: TransactionalConnection,
         private configService: ConfigService,
         private orderCalculator: OrderCalculator,
-        private paymentMethodService: PaymentMethodService,
+        private paymentService: PaymentService,
         private countryService: CountryService,
         private stockMovementService: StockMovementService,
         private productVariantService: ProductVariantService,
@@ -386,7 +386,7 @@ export class OrderModifier {
             const existingPayments = await this.getOrderPayments(ctx, order.id);
             const payment = existingPayments.find(p => idsAreEqual(p.id, input.refund?.paymentId));
             if (payment) {
-                const refund = await this.paymentMethodService.createRefund(
+                const refund = await this.paymentService.createRefund(
                     ctx,
                     refundInput,
                     order,

+ 9 - 2
packages/core/src/service/helpers/order-state-machine/order-state.ts

@@ -34,7 +34,7 @@ export const orderStateTransitions: Transitions<OrderState> = {
         to: ['PaymentAuthorized', 'PaymentSettled', 'AddingItems', 'Cancelled'],
     },
     PaymentAuthorized: {
-        to: ['PaymentSettled', 'Cancelled', 'Modifying'],
+        to: ['PaymentSettled', 'Cancelled', 'Modifying', 'ArrangingAdditionalPayment'],
     },
     PaymentSettled: {
         to: ['PartiallyDelivered', 'Delivered', 'PartiallyShipped', 'Shipped', 'Cancelled', 'Modifying'],
@@ -62,7 +62,14 @@ export const orderStateTransitions: Transitions<OrderState> = {
         ],
     },
     ArrangingAdditionalPayment: {
-        to: ['PaymentAuthorized', 'PaymentSettled', 'PartiallyShipped', 'Shipped', 'PartiallyDelivered'],
+        to: [
+            'PaymentAuthorized',
+            'PaymentSettled',
+            'PartiallyShipped',
+            'Shipped',
+            'PartiallyDelivered',
+            'Cancelled',
+        ],
     },
     Cancelled: {
         to: [],

+ 8 - 5
packages/core/src/service/helpers/payment-state-machine/payment-state.ts

@@ -9,22 +9,25 @@ import { Payment } from '../../../entity/payment/payment.entity';
  *
  * @docsCategory payment
  */
-export type PaymentState = 'Created' | 'Authorized' | 'Settled' | 'Declined' | 'Error';
+export type PaymentState = 'Created' | 'Authorized' | 'Settled' | 'Declined' | 'Error' | 'Cancelled';
 
 export const paymentStateTransitions: Transitions<PaymentState> = {
     Created: {
-        to: ['Authorized', 'Settled', 'Declined', 'Error'],
+        to: ['Authorized', 'Settled', 'Declined', 'Error', 'Cancelled'],
     },
     Authorized: {
-        to: ['Settled', 'Error'],
+        to: ['Settled', 'Error', 'Cancelled'],
     },
     Settled: {
-        to: [],
+        to: ['Cancelled'],
     },
     Declined: {
-        to: [],
+        to: ['Cancelled'],
     },
     Error: {
+        to: ['Cancelled'],
+    },
+    Cancelled: {
         to: [],
     },
 };

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

@@ -20,7 +20,9 @@ export function totalCoveredByPayments(order: Order, state?: PaymentState | Paym
         ? Array.isArray(state)
             ? order.payments.filter(p => state.includes(p.state))
             : order.payments.filter(p => p.state === state)
-        : order.payments.filter(p => p.state !== 'Error' && p.state !== 'Declined');
+        : order.payments.filter(
+              p => p.state !== 'Error' && p.state !== 'Declined' && p.state !== 'Cancelled',
+          );
     let total = 0;
     for (const payment of payments) {
         const refundTotal = summate(payment.refunds, 'total');

+ 26 - 42
packages/core/src/service/services/order.service.ts

@@ -30,6 +30,7 @@ import {
     SettlePaymentResult,
     SettleRefundInput,
     ShippingMethodQuote,
+    TransitionPaymentToStateResult,
     UpdateOrderNoteInput,
 } from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
@@ -745,14 +746,17 @@ export class OrderService {
         ctx: RequestContext,
         paymentId: ID,
         state: PaymentState,
-    ): Promise<Payment> {
-        const payment = await this.paymentService.transitionToState(ctx, paymentId, state);
-
-        const order = payment.order;
-
-        await this.transitionOrderIfTotalIsCovered(ctx, order);
-
-        return payment;
+    ): Promise<ErrorResultUnion<TransitionPaymentToStateResult, Payment>> {
+        const result = await this.paymentService.transitionToState(ctx, paymentId, state);
+        if (isGraphQlErrorResult(result)) {
+            return result;
+        }
+        const order = await this.findOne(ctx, result.order.id);
+        if (order) {
+            order.payments = await this.getOrderPayments(ctx, order.id);
+            await this.transitionOrderIfTotalIsCovered(ctx, order);
+        }
+        return result;
     }
 
     async addPaymentToOrder(
@@ -764,7 +768,7 @@ export class OrderService {
         if (order.state !== 'ArrangingPayment') {
             return new OrderPaymentStateError();
         }
-        const payment = await this.paymentMethodService.createPayment(
+        const payment = await this.paymentService.createPayment(
             ctx,
             order,
             order.totalWithTax,
@@ -817,14 +821,16 @@ export class OrderService {
         const amount = order.totalWithTax - totalCoveredByPayments(order);
         const modifications = await this.getOrderModifications(ctx, order.id);
         const unsettledModifications = modifications.filter(m => !m.isSettled);
-        const outstandingModificationsTotal = summate(unsettledModifications, 'priceChange');
-        if (outstandingModificationsTotal !== amount) {
-            throw new InternalServerError(
-                `The outstanding order amount (${amount}) should equal the unsettled OrderModifications total (${outstandingModificationsTotal})`,
-            );
+        if (0 < unsettledModifications.length) {
+            const outstandingModificationsTotal = summate(unsettledModifications, 'priceChange');
+            if (outstandingModificationsTotal !== amount) {
+                throw new InternalServerError(
+                    `The outstanding order amount (${amount}) should equal the unsettled OrderModifications total (${outstandingModificationsTotal})`,
+                );
+            }
         }
 
-        const payment = await this.paymentMethodService.createManualPayment(ctx, order, amount, input);
+        const payment = await this.paymentService.createManualPayment(ctx, order, amount, input);
         order.payments.push(payment);
         await this.connection.getRepository(ctx, Order).save(order, { reload: false });
         for (const modification of unsettledModifications) {
@@ -838,28 +844,11 @@ export class OrderService {
         ctx: RequestContext,
         paymentId: ID,
     ): Promise<ErrorResultUnion<SettlePaymentResult, Payment>> {
-        const payment = await this.connection.getEntityOrThrow(ctx, Payment, paymentId, {
-            relations: ['order'],
-        });
-        const settlePaymentResult = await this.paymentMethodService.settlePayment(
-            ctx,
-            payment,
-            payment.order,
-        );
-        if (settlePaymentResult.success) {
-            const fromState = payment.state;
-            const toState = 'Settled';
-            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);
+        const payment = await this.paymentService.settlePayment(ctx, paymentId);
+        if (!isGraphQlErrorResult(payment)) {
+            if (payment.state !== 'Settled') {
+                return new SettlePaymentError(payment.errorMessage || '');
             }
-            payment.metadata = this.mergePaymentMetadata(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),
-            );
             const orderTotalSettled = payment.amount === payment.order.totalWithTax;
             if (
                 orderTotalSettled &&
@@ -874,11 +863,6 @@ export class OrderService {
                     return orderTransitionResult;
                 }
             }
-        } else {
-            payment.errorMessage = settlePaymentResult.errorMessage;
-            payment.metadata = this.mergePaymentMetadata(payment.metadata, settlePaymentResult.metadata);
-            await this.connection.getRepository(ctx, Payment).save(payment, { reload: false });
-            return new SettlePaymentError(settlePaymentResult.errorMessage || '');
         }
         return payment;
     }
@@ -1109,7 +1093,7 @@ export class OrderService {
             return new AlreadyRefundedError(alreadyRefunded.refundId as string);
         }
 
-        return await this.paymentMethodService.createRefund(ctx, input, order, items, payment);
+        return await this.paymentService.createRefund(ctx, input, order, items, payment);
     }
 
     async settleRefund(ctx: RequestContext, input: SettleRefundInput): Promise<Refund> {

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

@@ -3,34 +3,22 @@ import { PaymentMethodQuote } from '@vendure/common/lib/generated-shop-types';
 import {
     ConfigurableOperationDefinition,
     CreatePaymentMethodInput,
-    ManualPaymentInput,
-    RefundOrderInput,
     UpdatePaymentMethodInput,
 } from '@vendure/common/lib/generated-types';
 import { omit } from '@vendure/common/lib/omit';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
-import { summate } 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 { IneligiblePaymentMethodError } from '../../common/error/generated-graphql-shop-errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { ConfigService } from '../../config/config.service';
 import { PaymentMethodEligibilityChecker } from '../../config/payment/payment-method-eligibility-checker';
 import { PaymentMethodHandler } from '../../config/payment/payment-method-handler';
-import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { Order } from '../../entity/order/order.entity';
 import { PaymentMethod } from '../../entity/payment-method/payment-method.entity';
-import { Payment } from '../../entity/payment/payment.entity';
-import { Refund } from '../../entity/refund/refund.entity';
 import { EventBus } from '../../event-bus/event-bus';
-import { PaymentStateTransitionEvent } from '../../event-bus/events/payment-state-transition-event';
-import { RefundStateTransitionEvent } from '../../event-bus/events/refund-state-transition-event';
 import { ConfigArgService } from '../helpers/config-arg/config-arg.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
-import { PaymentStateMachine } from '../helpers/payment-state-machine/payment-state-machine';
-import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state-machine';
 import { patchEntity } from '../helpers/utils/patch-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
@@ -40,8 +28,6 @@ export class PaymentMethodService {
         private connection: TransactionalConnection,
         private configService: ConfigService,
         private listQueryBuilder: ListQueryBuilder,
-        private paymentStateMachine: PaymentStateMachine,
-        private refundStateMachine: RefundStateMachine,
         private eventBus: EventBus,
         private configArgService: ConfigArgService,
     ) {}
@@ -132,117 +118,7 @@ export class PaymentMethodService {
         return results;
     }
 
-    async createPayment(
-        ctx: RequestContext,
-        order: Order,
-        amount: number,
-        method: string,
-        metadata: any,
-    ): Promise<Payment | IneligiblePaymentMethodError> {
-        const { paymentMethod, handler, checker } = await this.getMethodAndOperations(ctx, method);
-        if (paymentMethod.checker && checker) {
-            const eligible = await checker.check(ctx, order, paymentMethod.checker.args);
-            if (eligible === false || typeof eligible === 'string') {
-                return new IneligiblePaymentMethodError(typeof eligible === 'string' ? eligible : undefined);
-            }
-        }
-        const result = await handler.createPayment(
-            ctx,
-            order,
-            amount,
-            paymentMethod.handler.args,
-            metadata || {},
-        );
-        const initialState = 'Created';
-        const payment = await this.connection
-            .getRepository(ctx, Payment)
-            .save(new Payment({ ...result, method, state: initialState }));
-        await this.paymentStateMachine.transition(ctx, order, payment, result.state);
-        await this.connection.getRepository(ctx, Payment).save(payment, { reload: false });
-        this.eventBus.publish(
-            new PaymentStateTransitionEvent(initialState, result.state, ctx, payment, order),
-        );
-        return payment;
-    }
-
-    /**
-     * Creates a Payment from the manual payment mutation in the Admin API
-     */
-    async createManualPayment(ctx: RequestContext, order: Order, amount: number, input: ManualPaymentInput) {
-        const initialState = 'Created';
-        const endState = 'Settled';
-        const payment = await this.connection.getRepository(ctx, Payment).save(
-            new Payment({
-                amount,
-                order,
-                transactionId: input.transactionId,
-                metadata: input.metadata,
-                method: input.method,
-                state: initialState,
-            }),
-        );
-        await this.paymentStateMachine.transition(ctx, order, payment, endState);
-        await this.connection.getRepository(ctx, Payment).save(payment, { reload: false });
-        this.eventBus.publish(new PaymentStateTransitionEvent(initialState, endState, ctx, payment, order));
-        return payment;
-    }
-
-    async settlePayment(ctx: RequestContext, payment: Payment, order: Order) {
-        const { paymentMethod, handler } = await this.getMethodAndOperations(ctx, payment.method);
-        return handler.settlePayment(ctx, order, payment, paymentMethod.handler.args);
-    }
-
-    async createRefund(
-        ctx: RequestContext,
-        input: RefundOrderInput,
-        order: Order,
-        items: OrderItem[],
-        payment: Payment,
-    ): Promise<Refund | RefundStateTransitionError> {
-        const { paymentMethod, handler } = await this.getMethodAndOperations(ctx, payment.method);
-        const itemAmount = summate(items, 'proratedUnitPriceWithTax');
-        const refundAmount = itemAmount + input.shipping + input.adjustment;
-        let refund = new Refund({
-            payment,
-            orderItems: items,
-            items: itemAmount,
-            reason: input.reason,
-            adjustment: input.adjustment,
-            shipping: input.shipping,
-            total: refundAmount,
-            method: payment.method,
-            state: 'Pending',
-            metadata: {},
-        });
-        const createRefundResult = await handler.createRefund(
-            ctx,
-            input,
-            refundAmount,
-            order,
-            payment,
-            paymentMethod.handler.args,
-        );
-        if (createRefundResult) {
-            refund.transactionId = createRefundResult.transactionId || '';
-            refund.metadata = createRefundResult.metadata || {};
-        }
-        refund = await this.connection.getRepository(ctx, Refund).save(refund);
-        if (createRefundResult) {
-            const fromState = refund.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),
-            );
-        }
-        return refund;
-    }
-
-    private async getMethodAndOperations(
+    async getMethodAndOperations(
         ctx: RequestContext,
         method: string,
     ): Promise<{

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

@@ -1,19 +1,42 @@
 import { Injectable } from '@nestjs/common';
+import {
+    ManualPaymentInput,
+    RefundOrderInput,
+    SettlePaymentResult,
+} from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
+import { summate } from '@vendure/common/lib/shared-utils';
 
 import { RequestContext } from '../../api/common/request-context';
+import { ErrorResultUnion } from '../../common/error/error-result';
+import {
+    PaymentStateTransitionError,
+    RefundStateTransitionError,
+    SettlePaymentError,
+} from '../../common/error/generated-graphql-admin-errors';
+import { IneligiblePaymentMethodError } from '../../common/error/generated-graphql-shop-errors';
+import { PaymentMetadata } from '../../common/types/common-types';
+import { OrderItem } from '../../entity/order-item/order-item.entity';
+import { Order } from '../../entity/order/order.entity';
 import { Payment } from '../../entity/payment/payment.entity';
+import { Refund } from '../../entity/refund/refund.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { PaymentStateTransitionEvent } from '../../event-bus/events/payment-state-transition-event';
+import { RefundStateTransitionEvent } from '../../event-bus/events/refund-state-transition-event';
 import { PaymentState } from '../helpers/payment-state-machine/payment-state';
 import { PaymentStateMachine } from '../helpers/payment-state-machine/payment-state-machine';
+import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state-machine';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
+import { PaymentMethodService } from './payment-method.service';
+
 @Injectable()
 export class PaymentService {
     constructor(
         private connection: TransactionalConnection,
         private paymentStateMachine: PaymentStateMachine,
+        private refundStateMachine: RefundStateMachine,
+        private paymentMethodService: PaymentMethodService,
         private eventBus: EventBus,
     ) {}
 
@@ -31,11 +54,23 @@ export class PaymentService {
         });
     }
 
-    async transitionToState(ctx: RequestContext, paymentId: ID, state: PaymentState): Promise<Payment> {
+    async transitionToState(
+        ctx: RequestContext,
+        paymentId: ID,
+        state: PaymentState,
+    ): Promise<Payment | PaymentStateTransitionError> {
+        if (state === 'Settled') {
+            return this.settlePayment(ctx, paymentId);
+        }
         const payment = await this.findOneOrThrow(ctx, paymentId);
         const fromState = payment.state;
 
-        await this.paymentStateMachine.transition(ctx, payment.order, 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));
 
@@ -45,4 +80,161 @@ export class PaymentService {
     getNextStates(payment: Payment): ReadonlyArray<PaymentState> {
         return this.paymentStateMachine.getNextStates(payment);
     }
+
+    async createPayment(
+        ctx: RequestContext,
+        order: Order,
+        amount: number,
+        method: string,
+        metadata: any,
+    ): Promise<Payment | IneligiblePaymentMethodError> {
+        const { paymentMethod, handler, checker } = await this.paymentMethodService.getMethodAndOperations(
+            ctx,
+            method,
+        );
+        if (paymentMethod.checker && checker) {
+            const eligible = await checker.check(ctx, order, paymentMethod.checker.args);
+            if (eligible === false || typeof eligible === 'string') {
+                return new IneligiblePaymentMethodError(typeof eligible === 'string' ? eligible : undefined);
+            }
+        }
+        const result = await handler.createPayment(
+            ctx,
+            order,
+            amount,
+            paymentMethod.handler.args,
+            metadata || {},
+        );
+        const initialState = 'Created';
+        const payment = await this.connection
+            .getRepository(ctx, Payment)
+            .save(new Payment({ ...result, method, state: initialState }));
+        await this.paymentStateMachine.transition(ctx, order, payment, result.state);
+        await this.connection.getRepository(ctx, Payment).save(payment, { reload: false });
+        this.eventBus.publish(
+            new PaymentStateTransitionEvent(initialState, result.state, ctx, payment, order),
+        );
+        return payment;
+    }
+
+    async settlePayment(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 settlePaymentResult = await handler.settlePayment(
+            ctx,
+            payment.order,
+            payment,
+            paymentMethod.handler.args,
+        );
+        if (settlePaymentResult.success) {
+            const fromState = payment.state;
+            const toState = 'Settled';
+            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 = this.mergePaymentMetadata(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),
+            );
+        } else {
+            payment.errorMessage = settlePaymentResult.errorMessage;
+            payment.metadata = this.mergePaymentMetadata(payment.metadata, settlePaymentResult.metadata);
+            await this.connection.getRepository(ctx, Payment).save(payment, { reload: false });
+        }
+        return payment;
+    }
+    /**
+     * Creates a Payment from the manual payment mutation in the Admin API
+     */
+    async createManualPayment(ctx: RequestContext, order: Order, amount: number, input: ManualPaymentInput) {
+        const initialState = 'Created';
+        const endState = 'Settled';
+        const payment = await this.connection.getRepository(ctx, Payment).save(
+            new Payment({
+                amount,
+                order,
+                transactionId: input.transactionId,
+                metadata: input.metadata,
+                method: input.method,
+                state: initialState,
+            }),
+        );
+        await this.paymentStateMachine.transition(ctx, order, payment, endState);
+        await this.connection.getRepository(ctx, Payment).save(payment, { reload: false });
+        this.eventBus.publish(new PaymentStateTransitionEvent(initialState, endState, ctx, payment, order));
+        return payment;
+    }
+
+    async createRefund(
+        ctx: RequestContext,
+        input: RefundOrderInput,
+        order: Order,
+        items: OrderItem[],
+        payment: Payment,
+    ): Promise<Refund | RefundStateTransitionError> {
+        const { paymentMethod, handler } = await this.paymentMethodService.getMethodAndOperations(
+            ctx,
+            payment.method,
+        );
+        const itemAmount = summate(items, 'proratedUnitPriceWithTax');
+        const refundAmount = itemAmount + input.shipping + input.adjustment;
+        let refund = new Refund({
+            payment,
+            orderItems: items,
+            items: itemAmount,
+            reason: input.reason,
+            adjustment: input.adjustment,
+            shipping: input.shipping,
+            total: refundAmount,
+            method: payment.method,
+            state: 'Pending',
+            metadata: {},
+        });
+        const createRefundResult = await handler.createRefund(
+            ctx,
+            input,
+            refundAmount,
+            order,
+            payment,
+            paymentMethod.handler.args,
+        );
+        if (createRefundResult) {
+            refund.transactionId = createRefundResult.transactionId || '';
+            refund.metadata = createRefundResult.metadata || {};
+        }
+        refund = await this.connection.getRepository(ctx, Refund).save(refund);
+        if (createRefundResult) {
+            const fromState = refund.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),
+            );
+        }
+        return refund;
+    }
+
+    private mergePaymentMetadata(m1: PaymentMetadata, m2?: PaymentMetadata): PaymentMetadata {
+        if (!m2) {
+            return m1;
+        }
+        const merged = { ...m1, ...m2 };
+        if (m1.public && m1.public) {
+            merged.public = { ...m1.public, ...m2.public };
+        }
+        return merged;
+    }
 }

+ 1 - 1
packages/dev-server/.gitignore

@@ -1,7 +1,7 @@
 assets
 vendure
 test-emails
-vendure.sqlite
+vendure.sqlite*
 vendure-import-error.log
 load-testing/data-sources/products*.csv
 load-testing/results/**/*.json

+ 0 - 0
packages/dev-server/vendure.sqlite-shm


+ 0 - 0
packages/dev-server/vendure.sqlite-wal


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

@@ -1402,6 +1402,20 @@ export type Fulfillment = Node & {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type Payment = Node & {
+    nextStates: Array<Scalars['String']>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    method: Scalars['String'];
+    amount: Scalars['Int'];
+    state: Scalars['String'];
+    transactionId?: Maybe<Scalars['String']>;
+    errorMessage?: Maybe<Scalars['String']>;
+    refunds: Array<Refund>;
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
 export type OrderModification = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -1774,20 +1788,6 @@ export type PaymentMethod = Node & {
     handler: ConfigurableOperation;
 };
 
-export type Payment = Node & {
-    nextStates: Array<Scalars['String']>;
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    method: Scalars['String'];
-    amount: Scalars['Int'];
-    state: Scalars['String'];
-    transactionId?: Maybe<Scalars['String']>;
-    errorMessage?: Maybe<Scalars['String']>;
-    refunds: Array<Refund>;
-    metadata?: Maybe<Scalars['JSON']>;
-};
-
 export type Product = Node & {
     enabled: Scalars['Boolean'];
     channels: Array<Channel>;
@@ -3693,9 +3693,9 @@ export type OrderLine = Node & {
     unitPrice: Scalars['Int'];
     /** The price of a single unit, including tax but excluding discounts */
     unitPriceWithTax: Scalars['Int'];
-    /** If the unitPrice has changed since initially added to Order */
+    /** Non-zero if the unitPrice has changed since it was initially added to Order */
     unitPriceChangeSinceAdded: Scalars['Int'];
-    /** If the unitPriceWithTax has changed since initially added to Order */
+    /** Non-zero if the unitPriceWithTax has changed since it was initially added to Order */
     unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
     /**
      * The price of a single unit including discounts, excluding tax.

File diff suppressed because it is too large
+ 0 - 0
schema-admin.json


File diff suppressed because it is too large
+ 0 - 0
schema-shop.json


Some files were not shown because too many files changed in this diff