Browse Source

feat(core): Allow payment handler to reject settlement

Relates to #117
Michael Bromley 6 years ago
parent
commit
4cbae468c2

+ 200 - 7
packages/core/e2e/order.e2e-spec.ts

@@ -2,12 +2,29 @@
 import gql from 'graphql-tag';
 import path from 'path';
 
+import { ID } from '../../common/lib/shared-types';
+import { PaymentMethodHandler } from '../src/config/payment-method/payment-method-handler';
+
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { ORDER_FRAGMENT, ORDER_WITH_LINES_FRAGMENT } from './graphql/fragments';
-import { GetCustomerList, GetOrder, GetOrderList } from './graphql/generated-e2e-admin-types';
-import { AddItemToOrder } from './graphql/generated-e2e-shop-types';
+import { GetCustomerList, GetOrder, GetOrderList, OrderFragment, SettlePayment } from './graphql/generated-e2e-admin-types';
+import {
+    AddItemToOrder,
+    AddPaymentToOrder,
+    GetShippingMethods,
+    SetShippingAddress,
+    SetShippingMethod,
+    TransitionToState,
+} from './graphql/generated-e2e-shop-types';
 import { GET_CUSTOMER_LIST } from './graphql/shared-definitions';
-import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
+import {
+    ADD_ITEM_TO_ORDER,
+    ADD_PAYMENT,
+    GET_ELIGIBLE_SHIPPING_METHODS,
+    SET_SHIPPING_ADDRESS,
+    SET_SHIPPING_METHOD,
+    TRANSITION_TO_STATE,
+} from './graphql/shop-definitions';
 import { TestAdminClient, TestShopClient } from './test-client';
 import { TestServer } from './test-server';
 
@@ -16,13 +33,24 @@ describe('Orders resolver', () => {
     const shopClient = new TestShopClient();
     const server = new TestServer();
     let customers: GetCustomerList.Items[];
+    let orders: OrderFragment[];
     const password = 'test';
 
     beforeAll(async () => {
-        const token = await server.init({
-            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
-            customerCount: 2,
-        });
+        const token = await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+                customerCount: 2,
+            },
+            {
+                paymentOptions: {
+                    paymentMethodHandlers: [
+                        twoStagePaymentMethod,
+                        failsToSettlePaymentMethod,
+                    ],
+                },
+            },
+        );
         await adminClient.init();
 
         // Create a couple of orders to be queried
@@ -54,14 +82,169 @@ describe('Orders resolver', () => {
     it('orders', async () => {
         const result = await adminClient.query<GetOrderList.Query>(GET_ORDERS_LIST);
         expect(result.orders.items.map(o => o.id)).toEqual(['T_1', 'T_2']);
+        orders = result.orders.items;
     });
 
     it('order', async () => {
         const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, { id: 'T_2' });
         expect(result.order!.id).toBe('T_2');
     });
+
+    describe('payments', () => {
+
+        it('settlePayment fails', async () => {
+            await shopClient.asUserWithCredentials(customers[0].emailAddress, password);
+            await proceedToArrangingPayment(shopClient);
+
+            const { addPaymentToOrder } = await shopClient.query<
+                AddPaymentToOrder.Mutation,
+                AddPaymentToOrder.Variables
+                >(ADD_PAYMENT, {
+                input: {
+                    method: failsToSettlePaymentMethod.code,
+                    metadata: {
+                        baz: 'quux',
+                    },
+                },
+            });
+            const order = addPaymentToOrder!;
+
+            expect(order.state).toBe('PaymentAuthorized');
+
+            const payment = order.payments![0];
+            const { settlePayment } = await adminClient.query<SettlePayment.Mutation, SettlePayment.Variables>(SETTLE_PAYMENT, {
+                id: payment.id,
+            });
+
+            expect(settlePayment!.id).toBe(payment.id);
+            expect(settlePayment!.state).toBe('Authorized');
+
+            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, { id: order.id });
+
+            expect(result.order!.state).toBe('PaymentAuthorized');
+        });
+
+        it('settlePayment succeeds', async () => {
+            await shopClient.asUserWithCredentials(customers[1].emailAddress, password);
+            await proceedToArrangingPayment(shopClient);
+
+            const { addPaymentToOrder } = await shopClient.query<
+                AddPaymentToOrder.Mutation,
+                AddPaymentToOrder.Variables
+                >(ADD_PAYMENT, {
+                input: {
+                    method: twoStagePaymentMethod.code,
+                    metadata: {
+                        baz: 'quux',
+                    },
+                },
+            });
+            const order = addPaymentToOrder!;
+
+            expect(order.state).toBe('PaymentAuthorized');
+
+            const payment = order.payments![0];
+            const { settlePayment } = await adminClient.query<SettlePayment.Mutation, SettlePayment.Variables>(SETTLE_PAYMENT, {
+                id: payment.id,
+            });
+
+            expect(settlePayment!.id).toBe(payment.id);
+            expect(settlePayment!.state).toBe('Settled');
+            // further metadata is combined into existing object
+            expect(settlePayment!.metadata).toEqual({
+                baz: 'quux',
+                moreData: 42,
+            });
+
+            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, { id: order.id });
+
+            expect(result.order!.state).toBe('PaymentSettled');
+            expect(result.order!.payments![0].state).toBe('Settled');
+        });
+    });
+});
+
+/**
+ * A two-stage (authorize, capture) payment method.
+ */
+const twoStagePaymentMethod = new PaymentMethodHandler({
+    code: 'authorize-only-payment-method',
+    description: 'Test Payment Method',
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Authorized',
+            transactionId: '12345',
+            metadata,
+        };
+    },
+    settlePayment: () => {
+        return {
+            success: true,
+            metadata: {
+                moreData: 42,
+            },
+        };
+    },
+});
+
+/**
+ * A payment method where calling `settlePayment` always fails.
+ */
+const failsToSettlePaymentMethod = new PaymentMethodHandler({
+    code: 'fails-to-settle-payment-method',
+    description: 'Test Payment Method',
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Authorized',
+            transactionId: '12345',
+            metadata,
+        };
+    },
+    settlePayment: () => {
+        return {
+            success: false,
+            errorMessage: 'Something went horribly wrong',
+        };
+    },
 });
 
+async function proceedToArrangingPayment(shopClient: TestShopClient): Promise<ID> {
+    await shopClient.query<SetShippingAddress.Mutation, SetShippingAddress.Variables>(
+        SET_SHIPPING_ADDRESS,
+        {
+            input: {
+                fullName: 'name',
+                streetLine1: '12 the street',
+                city: 'foo',
+                postalCode: '123456',
+                countryCode: 'US',
+            },
+        },
+    );
+
+    const { eligibleShippingMethods } = await shopClient.query<GetShippingMethods.Query>(
+        GET_ELIGIBLE_SHIPPING_METHODS,
+    );
+
+    await shopClient.query<SetShippingMethod.Mutation, SetShippingMethod.Variables>(
+        SET_SHIPPING_METHOD,
+        {
+            id: eligibleShippingMethods[1].id,
+        },
+    );
+
+    const { transitionOrderToState } = await shopClient.query<
+        TransitionToState.Mutation,
+        TransitionToState.Variables
+        >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
+
+    return transitionOrderToState!.id;
+}
+
 export const GET_ORDERS_LIST = gql`
     query GetOrderList($options: OrderListOptions) {
         orders(options: $options) {
@@ -82,3 +265,13 @@ export const GET_ORDER = gql`
     }
     ${ORDER_WITH_LINES_FRAGMENT}
 `;
+
+export const SETTLE_PAYMENT = gql`
+    mutation SettlePayment($id: ID!) {
+        settlePayment(id: $id) {
+            id
+            state
+            metadata
+        }
+    }
+`;

+ 17 - 6
packages/core/src/config/payment-method/example-payment-method-config.ts

@@ -1,6 +1,6 @@
 import { ConfigArgType } from '@vendure/common/lib/generated-types';
 
-import { PaymentConfig, PaymentMethodHandler } from './payment-method-handler';
+import { CreatePaymentResult, PaymentMethodHandler } from './payment-method-handler';
 
 /**
  * A dummy API to simulate an SDK provided by a popular payments service.
@@ -14,6 +14,9 @@ const gripeSDK = {
                     .substr(3),
             });
         },
+        capture: (transactionId: string) => {
+            return true;
+        },
     },
 };
 
@@ -25,9 +28,10 @@ export const examplePaymentHandler = new PaymentMethodHandler({
     code: 'example-payment-provider',
     description: 'Example Payment Provider',
     args: {
+        automaticCapture: ConfigArgType.BOOLEAN,
         apiKey: ConfigArgType.STRING,
     },
-    createPayment: async (order, args, metadata): Promise<PaymentConfig> => {
+    createPayment: async (order, args, metadata): Promise<CreatePaymentResult> => {
         try {
             const result = await gripeSDK.charges.create({
                 apiKey: args.apiKey,
@@ -36,11 +40,9 @@ export const examplePaymentHandler = new PaymentMethodHandler({
             });
             return {
                 amount: order.total,
-                state: 'Settled' as 'Settled',
+                state: args.automaticCapture ? 'Settled' : 'Authorized',
                 transactionId: result.id.toString(),
-                metadata: {
-                    sampleMetadata: 'some arbitrary values',
-                },
+                metadata,
             };
         } catch (err) {
             return {
@@ -52,4 +54,13 @@ export const examplePaymentHandler = new PaymentMethodHandler({
             };
         }
     },
+    settlePayment: async (order, payment, args) => {
+        const result = await gripeSDK.charges.capture(payment.transactionId);
+        return {
+            success: result,
+            metadata: {
+                captureId: '1234567',
+            },
+        };
+    },
 });

+ 40 - 5
packages/core/src/config/payment-method/payment-method-handler.ts

@@ -8,7 +8,7 @@ import {
 } from '../../common/configurable-operation';
 import { StateMachineConfig } from '../../common/finite-state-machine';
 import { Order } from '../../entity/order/order.entity';
-import { PaymentMetadata } from '../../entity/payment/payment.entity';
+import { Payment, PaymentMetadata } from '../../entity/payment/payment.entity';
 import {
     PaymentState,
     PaymentTransitionData,
@@ -42,13 +42,19 @@ export type OnTransitionStartFn<T extends PaymentMethodArgs> = (
  *
  * @docsCategory payment
  */
-export interface PaymentConfig {
+export interface CreatePaymentResult {
     amount: number;
     state: Exclude<PaymentState, 'Refunded'>;
     transactionId?: string;
     metadata?: PaymentMetadata;
 }
 
+export interface SettlePaymentResult {
+    success: boolean;
+    errorMessage?: string;
+    metadata?: PaymentMetadata;
+}
+
 /**
  * @description
  * This function contains the logic for creating a payment. See {@link PaymentMethodHandler} for an example.
@@ -59,7 +65,19 @@ export type CreatePaymentFn<T extends PaymentMethodArgs> = (
     order: Order,
     args: ConfigArgValues<T>,
     metadata: PaymentMetadata,
-) => PaymentConfig | Promise<PaymentConfig>;
+) => CreatePaymentResult | Promise<CreatePaymentResult>;
+
+/**
+ * @description
+ * This function contains the logic for settling a payment. See {@link PaymentMethodHandler} for an example.
+ *
+ * @docsCategory payment
+ */
+export type SettlePaymentFn<T extends PaymentMethodArgs> = (
+    order: Order,
+    payment: Payment,
+    args: ConfigArgValues<T>,
+) => SettlePaymentResult | Promise<SettlePaymentResult>;
 
 /**
  * @description
@@ -80,11 +98,16 @@ export interface PaymentMethodConfigOptions<T extends PaymentMethodArgs = Paymen
     description: string;
     /**
      * @description
-     * This function provides the actual logic for creating a payment. For example,
+     * This function provides the logic for creating a payment. For example,
      * it may call out to a third-party service with the data and should return a
-     * {@link PaymentConfig} object contains the details of the payment.
+     * {@link CreatePaymentResult} object contains the details of the payment.
      */
     createPayment: CreatePaymentFn<T>;
+    /**
+     * @description
+     * This function provides the logic for settling a payment.
+     */
+    settlePayment: SettlePaymentFn<T>;
     /**
      * @description
      * Optional provider-specific arguments which, when specified, are
@@ -162,6 +185,7 @@ export class PaymentMethodHandler<T extends PaymentMethodArgs = PaymentMethodArg
     /** @internal */
     readonly args: T;
     private readonly createPaymentFn: CreatePaymentFn<T>;
+    private readonly settlePaymentFn: SettlePaymentFn<T>;
     private readonly onTransitionStartFn?: OnTransitionStartFn<T>;
 
     constructor(config: PaymentMethodConfigOptions<T>) {
@@ -169,6 +193,7 @@ export class PaymentMethodHandler<T extends PaymentMethodArgs = PaymentMethodArg
         this.description = config.description;
         this.args = config.args;
         this.createPaymentFn = config.createPayment;
+        this.settlePaymentFn = config.settlePayment;
         this.onTransitionStartFn = config.onStateTransitionStart;
     }
 
@@ -186,6 +211,16 @@ export class PaymentMethodHandler<T extends PaymentMethodArgs = PaymentMethodArg
         };
     }
 
+    /**
+     * @description
+     * Called internally to settle a payment
+     *
+     * @internal
+     */
+    async settlePayment(order: Order, payment: Payment, args: ConfigArg[]) {
+        return this.settlePaymentFn(order, payment, argsArrayToHash(args));
+    }
+
     /**
      * @description
      * This function is called before the state of a Payment is transitioned. If the PaymentMethodHandler

+ 9 - 9
packages/core/src/service/services/order.service.ts

@@ -5,12 +5,7 @@ import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
-import {
-    EntityNotFoundError,
-    IllegalOperationError,
-    OrderItemsLimitError,
-    UserInputError,
-} from '../../common/error/errors';
+import { EntityNotFoundError, IllegalOperationError, OrderItemsLimitError, UserInputError, } from '../../common/error/errors';
 import { generatePublicId } from '../../common/generate-public-id';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { idsAreEqual } from '../../common/utils';
@@ -309,9 +304,14 @@ export class OrderService {
 
     async settlePayment(ctx: RequestContext, paymentId: ID): Promise<Payment> {
         const payment = await getEntityOrThrow(this.connection, Payment, paymentId, { relations: ['order'] });
-        await this.paymentStateMachine.transition(ctx, payment.order, payment, 'Settled');
-        if (payment.amount === payment.order.total) {
-            await this.transitionToState(ctx, payment.order.id, 'PaymentSettled');
+        const settlePaymentResult = await this.paymentMethodService.settlePayment(payment, payment.order);
+        if (settlePaymentResult.success) {
+            await this.paymentStateMachine.transition(ctx, payment.order, payment, 'Settled');
+            payment.metadata = { ...payment.metadata, ...settlePaymentResult.metadata };
+            await this.connection.getRepository(Payment).save(payment);
+            if (payment.amount === payment.order.total) {
+                await this.transitionToState(ctx, payment.order.id, 'PaymentSettled');
+            }
         }
         return payment;
     }

+ 43 - 17
packages/core/src/service/services/payment-method.service.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
-import { ConfigArgType, UpdatePaymentMethodInput } from '@vendure/common/lib/generated-types';
+import { ConfigArg, ConfigArgType, UpdatePaymentMethodInput } from '@vendure/common/lib/generated-types';
 import { omit } from '@vendure/common/lib/omit';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { assertNever } from '@vendure/common/lib/shared-utils';
@@ -63,6 +63,18 @@ export class PaymentMethodService {
     }
 
     async createPayment(order: Order, method: string, metadata: PaymentMetadata): Promise<Payment> {
+        const { paymentMethod, handler } = await this.getMethodAndHandler(method);
+        const result = await handler.createPayment(order, paymentMethod.configArgs, metadata || {});
+        const payment = new Payment(result);
+        return this.connection.getRepository(Payment).save(payment);
+    }
+
+    async settlePayment(payment: Payment, order: Order) {
+        const { paymentMethod, handler } = await this.getMethodAndHandler(payment.method);
+        return handler.settlePayment(order, payment, paymentMethod.configArgs);
+    }
+
+    private async getMethodAndHandler(method: string): Promise<{ paymentMethod: PaymentMethod, handler: PaymentMethodHandler }> {
         const paymentMethod = await this.connection.getRepository(PaymentMethod).findOne({
             where: {
                 code: method,
@@ -76,9 +88,7 @@ export class PaymentMethodService {
         if (!handler) {
             throw new UserInputError(`error.no-payment-handler-with-code`, { code: paymentMethod.code });
         }
-        const result = await handler.createPayment(order, paymentMethod.configArgs, metadata || {});
-        const payment = new Payment(result);
-        return this.connection.getRepository(Payment).save(payment);
+        return { paymentMethod, handler };
     }
 
     private async ensurePaymentMethodsExist() {
@@ -91,7 +101,18 @@ export class PaymentMethodService {
         const toRemove = existingPaymentMethods.filter(
             h => !paymentMethodHandlers.find(pm => pm.code === h.code),
         );
+        const toUpdate = existingPaymentMethods.filter(
+            h => !toCreate.find(x => x.code === h.code) && !toRemove.find(x => x.code === h.code),
+        );
 
+        for (const paymentMethod of toUpdate) {
+            const handler = paymentMethodHandlers.find(h => h.code === paymentMethod.code);
+            if (!handler) {
+                continue;
+            }
+            paymentMethod.configArgs = this.buildConfigArgsArray(handler, paymentMethod.configArgs);
+            await this.connection.getRepository(PaymentMethod).save(paymentMethod);
+        }
         for (const handler of toCreate) {
             let paymentMethod = existingPaymentMethods.find(pm => pm.code === handler.code);
 
@@ -102,24 +123,29 @@ export class PaymentMethodService {
                     configArgs: [],
                 });
             }
-
-            for (const [name, type] of Object.entries(handler.args)) {
-                if (!paymentMethod.configArgs.find(ca => ca.name === name)) {
-                    paymentMethod.configArgs.push({
-                        name,
-                        type,
-                        value: this.getDefaultValue(type),
-                    });
-                }
-            }
-            paymentMethod.configArgs = paymentMethod.configArgs.filter(ca =>
-                handler.args.hasOwnProperty(ca.name),
-            );
+            paymentMethod.configArgs = this.buildConfigArgsArray(handler, paymentMethod.configArgs);
             await this.connection.getRepository(PaymentMethod).save(paymentMethod);
         }
         await this.connection.getRepository(PaymentMethod).remove(toRemove);
     }
 
+    private buildConfigArgsArray(handler: PaymentMethodHandler, existingConfigArgs: ConfigArg[]): ConfigArg[] {
+        let configArgs: ConfigArg[] = [];
+        for (const [name, type] of Object.entries(handler.args)) {
+            if (!existingConfigArgs.find(ca => ca.name === name)) {
+                configArgs.push({
+                    name,
+                    type,
+                    value: this.getDefaultValue(type),
+                });
+            }
+        }
+        configArgs = configArgs.filter(ca =>
+            handler.args.hasOwnProperty(ca.name),
+        );
+        return [...existingConfigArgs, ...configArgs];
+    }
+
     private getDefaultValue(type: PaymentMethodArgType): string {
         switch (type) {
             case ConfigArgType.STRING: