From 4cbae468c2cd99bf419757fcfe1555faa359cf07 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Mon, 24 Jun 2019 11:36:45 +0200 Subject: [PATCH] feat(core): Allow payment handler to reject settlement Relates to #117 --- packages/core/e2e/order.e2e-spec.ts | 207 +++++++++++++++++- .../example-payment-method-config.ts | 23 +- .../payment-method/payment-method-handler.ts | 45 +++- .../src/service/services/order.service.ts | 18 +- .../services/payment-method.service.ts | 60 +++-- 5 files changed, 309 insertions(+), 44 deletions(-) diff --git a/packages/core/e2e/order.e2e-spec.ts b/packages/core/e2e/order.e2e-spec.ts index e0aa77bdcc..b35d1b7e9f 100644 --- a/packages/core/e2e/order.e2e-spec.ts +++ b/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(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(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(SETTLE_PAYMENT, { + id: payment.id, + }); + + expect(settlePayment!.id).toBe(payment.id); + expect(settlePayment!.state).toBe('Authorized'); + + const result = await adminClient.query(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(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(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 { + await shopClient.query( + SET_SHIPPING_ADDRESS, + { + input: { + fullName: 'name', + streetLine1: '12 the street', + city: 'foo', + postalCode: '123456', + countryCode: 'US', + }, + }, + ); + + const { eligibleShippingMethods } = await shopClient.query( + GET_ELIGIBLE_SHIPPING_METHODS, + ); + + await shopClient.query( + 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 + } + } +`; diff --git a/packages/core/src/config/payment-method/example-payment-method-config.ts b/packages/core/src/config/payment-method/example-payment-method-config.ts index 9e909aa784..9b78e61fe7 100644 --- a/packages/core/src/config/payment-method/example-payment-method-config.ts +++ b/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 => { + createPayment: async (order, args, metadata): Promise => { 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', + }, + }; + }, }); diff --git a/packages/core/src/config/payment-method/payment-method-handler.ts b/packages/core/src/config/payment-method/payment-method-handler.ts index ccc1f1467a..4f98f8e332 100644 --- a/packages/core/src/config/payment-method/payment-method-handler.ts +++ b/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 = ( * * @docsCategory payment */ -export interface PaymentConfig { +export interface CreatePaymentResult { amount: number; state: Exclude; 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 = ( order: Order, args: ConfigArgValues, metadata: PaymentMetadata, -) => PaymentConfig | Promise; +) => CreatePaymentResult | Promise; + +/** + * @description + * This function contains the logic for settling a payment. See {@link PaymentMethodHandler} for an example. + * + * @docsCategory payment + */ +export type SettlePaymentFn = ( + order: Order, + payment: Payment, + args: ConfigArgValues, +) => SettlePaymentResult | Promise; /** * @description @@ -80,11 +98,16 @@ export interface PaymentMethodConfigOptions; + /** + * @description + * This function provides the logic for settling a payment. + */ + settlePayment: SettlePaymentFn; /** * @description * Optional provider-specific arguments which, when specified, are @@ -162,6 +185,7 @@ export class PaymentMethodHandler; + private readonly settlePaymentFn: SettlePaymentFn; private readonly onTransitionStartFn?: OnTransitionStartFn; constructor(config: PaymentMethodConfigOptions) { @@ -169,6 +193,7 @@ export class PaymentMethodHandler { 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; } diff --git a/packages/core/src/service/services/payment-method.service.ts b/packages/core/src/service/services/payment-method.service.ts index 16d76e1fb0..8189f24e06 100644 --- a/packages/core/src/service/services/payment-method.service.ts +++ b/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 { + 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: