Skip to content

Commit

Permalink
fix(core): Correctly call PaymentMethodHandler.onStateTransitionStart
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Jul 17, 2020
1 parent 355ce14 commit 143e62f
Show file tree
Hide file tree
Showing 6 changed files with 70 additions and 55 deletions.
10 changes: 7 additions & 3 deletions packages/core/e2e/fixtures/test-payment-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ export const testSuccessfulPaymentMethod = new PaymentMethodHandler({
metadata,
};
},
settlePayment: order => ({
settlePayment: (order) => ({
success: true,
}),
});

export const onTransitionSpy = jest.fn();
/**
* A two-stage (authorize, capture) payment method, with no createRefund method.
*/
Expand All @@ -42,6 +43,9 @@ export const twoStagePaymentMethod = new PaymentMethodHandler({
},
};
},
onStateTransitionStart: (fromState, toState, data) => {
onTransitionSpy(fromState, toState, data);
},
});

/**
Expand Down Expand Up @@ -104,7 +108,7 @@ export const testFailingPaymentMethod = new PaymentMethodHandler({
metadata,
};
},
settlePayment: order => ({
settlePayment: (order) => ({
success: true,
}),
});
Expand All @@ -120,7 +124,7 @@ export const testErrorPaymentMethod = new PaymentMethodHandler({
metadata,
};
},
settlePayment: order => ({
settlePayment: (order) => ({
success: true,
}),
});
10 changes: 9 additions & 1 deletion packages/core/e2e/order.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-conf

import {
failsToSettlePaymentMethod,
onTransitionSpy,
singleStageRefundablePaymentMethod,
twoStagePaymentMethod,
} from './fixtures/test-payment-methods';
Expand Down Expand Up @@ -149,12 +150,16 @@ describe('Orders resolver', () => {
expect(result.order!.state).toBe('PaymentAuthorized');
});

it('settlePayment succeeds', async () => {
it('settlePayment succeeds, onStateTransitionStart called', async () => {
onTransitionSpy.mockClear();
await shopClient.asUserWithCredentials(customers[1].emailAddress, password);
await proceedToArrangingPayment(shopClient);
const order = await addPaymentToOrder(shopClient, twoStagePaymentMethod);

expect(order.state).toBe('PaymentAuthorized');
expect(onTransitionSpy).toHaveBeenCalledTimes(1);
expect(onTransitionSpy.mock.calls[0][0]).toBe('Created');
expect(onTransitionSpy.mock.calls[0][1]).toBe('Authorized');

const payment = order.payments![0];
const { settlePayment } = await adminClient.query<
Expand All @@ -171,6 +176,9 @@ describe('Orders resolver', () => {
baz: 'quux',
moreData: 42,
});
expect(onTransitionSpy).toHaveBeenCalledTimes(2);
expect(onTransitionSpy.mock.calls[1][0]).toBe('Authorized');
expect(onTransitionSpy.mock.calls[1][1]).toBe('Settled');

const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
id: order.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { StateMachineConfig } from './types';

/**
* @description
* A simple type-safe finite state machine
* A simple type-safe finite state machine. This is used internally to control the Order process, ensuring that
* the state of Orders, Payments and Refunds follows a well-defined behaviour.
*
* @docsCategory StateMachine
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/common/finite-state-machine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export type OnTransitionEndFn<T extends string, Data> = (

/**
* @description
* The config object used to instantiate a new FSM instance.
* The config object used to instantiate a new {@link FSM} instance.
*
* @docsCategory StateMachine
* @docsPage StateMachineConfig
Expand Down
32 changes: 8 additions & 24 deletions packages/core/src/config/payment-method/payment-method-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
ConfigurableOperationDefOptions,
LocalizedStringArray,
} from '../../common/configurable-operation';
import { StateMachineConfig } from '../../common/finite-state-machine/types';
import { OnTransitionStartFn, StateMachineConfig } from '../../common/finite-state-machine/types';
import { Order } from '../../entity/order/order.entity';
import { Payment, PaymentMetadata } from '../../entity/payment/payment.entity';
import {
Expand All @@ -20,24 +20,9 @@ import { RefundState } from '../../service/helpers/refund-state-machine/refund-s

export type PaymentMethodArgType = ConfigArgSubset<'int' | 'string' | 'boolean'>;
export type PaymentMethodArgs = ConfigArgs<PaymentMethodArgType>;
export type OnTransitionStartReturnType = ReturnType<Required<StateMachineConfig<any>>['onTransitionStart']>;

/**
* @description
* The signature of the function defined by `onStateTransitionStart` in {@link PaymentMethodConfigOptions}.
*
* This function is called before the state of a Payment is transitioned. Its
* return value used to determine whether the transition can occur.
*
* @docsCategory payment
* @docsPage Payment Method Types
*/
export type OnTransitionStartFn<T extends PaymentMethodArgs> = (
fromState: PaymentState,
toState: PaymentState,
args: ConfigArgValues<T>,
data: PaymentTransitionData,
) => OnTransitionStartReturnType;
export type OnPaymentTransitionStartReturnType = ReturnType<
Required<StateMachineConfig<any>>['onTransitionStart']
>;

/**
* @description
Expand Down Expand Up @@ -172,7 +157,7 @@ export interface PaymentMethodConfigOptions<T extends PaymentMethodArgs = Paymen
* This function, when specified, will be invoked before any transition from one {@link PaymentState} to another.
* The return value (a sync / async `boolean`) is used to determine whether the transition is permitted.
*/
onStateTransitionStart?: OnTransitionStartFn<T>;
onStateTransitionStart?: OnTransitionStartFn<PaymentState, PaymentTransitionData>;
}

/**
Expand Down Expand Up @@ -233,7 +218,7 @@ export class PaymentMethodHandler<
private readonly createPaymentFn: CreatePaymentFn<T>;
private readonly settlePaymentFn: SettlePaymentFn<T>;
private readonly createRefundFn?: CreateRefundFn<T>;
private readonly onTransitionStartFn?: OnTransitionStartFn<T>;
private readonly onTransitionStartFn?: OnTransitionStartFn<PaymentState, PaymentTransitionData>;

constructor(config: PaymentMethodConfigOptions<T>) {
super(config);
Expand Down Expand Up @@ -297,11 +282,10 @@ export class PaymentMethodHandler<
onStateTransitionStart(
fromState: PaymentState,
toState: PaymentState,
args: ConfigArg[],
data: PaymentTransitionData,
): OnTransitionStartReturnType {
): OnPaymentTransitionStartReturnType {
if (typeof this.onTransitionStartFn === 'function') {
return this.onTransitionStartFn(fromState, toState, argsArrayToHash(args), data);
return this.onTransitionStartFn(fromState, toState, data);
} else {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,22 @@ import { RequestContext } from '../../../api/common/request-context';
import { IllegalOperationError } from '../../../common/error/errors';
import { FSM } from '../../../common/finite-state-machine/finite-state-machine';
import { StateMachineConfig } from '../../../common/finite-state-machine/types';
import { awaitPromiseOrObservable } from '../../../common/utils';
import { ConfigService } from '../../../config/config.service';
import { Order } from '../../../entity/order/order.entity';
import { Payment } from '../../../entity/payment/payment.entity';
import { HistoryService } from '../../services/history.service';
import { OrderState, OrderTransitionData } from '../order-state-machine/order-state';

import { PaymentState, paymentStateTransitions, PaymentTransitionData } from './payment-state';

@Injectable()
export class PaymentStateMachine {
private readonly config: StateMachineConfig<PaymentState, PaymentTransitionData> = {
transitions: paymentStateTransitions,
onTransitionStart: async (fromState, toState, data) => {
return true;
},
onTransitionEnd: async (fromState, toState, data) => {
await this.historyService.createHistoryEntryForOrder({
ctx: data.ctx,
orderId: data.order.id,
type: HistoryEntryType.ORDER_PAYMENT_TRANSITION,
data: {
paymentId: data.payment.id,
from: fromState,
to: toState,
},
});
},
onError: (fromState, toState, message) => {
throw new IllegalOperationError(message || 'error.cannot-transition-payment-from-to', {
fromState,
toState,
});
},
};
private readonly config: StateMachineConfig<PaymentState, PaymentTransitionData>;

constructor(private configService: ConfigService, private historyService: HistoryService) {}
constructor(private configService: ConfigService, private historyService: HistoryService) {
this.config = this.initConfig();
}

getNextStates(payment: Payment): ReadonlyArray<PaymentState> {
const fsm = new FSM(this.config, payment.state);
Expand All @@ -51,4 +32,41 @@ export class PaymentStateMachine {
await fsm.transitionTo(state, { ctx, order, payment });
payment.state = state;
}

private initConfig(): StateMachineConfig<PaymentState, PaymentTransitionData> {
const { paymentMethodHandlers } = this.configService.paymentOptions;
return {
transitions: paymentStateTransitions,
onTransitionStart: async (fromState, toState, data) => {
for (const handler of paymentMethodHandlers) {
if (data.payment.method === handler.code) {
const result = await awaitPromiseOrObservable(
handler.onStateTransitionStart(fromState, toState, data),
);
if (result !== true) {
return result;
}
}
}
},
onTransitionEnd: async (fromState, toState, data) => {
await this.historyService.createHistoryEntryForOrder({
ctx: data.ctx,
orderId: data.order.id,
type: HistoryEntryType.ORDER_PAYMENT_TRANSITION,
data: {
paymentId: data.payment.id,
from: fromState,
to: toState,
},
});
},
onError: (fromState, toState, message) => {
throw new IllegalOperationError(message || 'error.cannot-transition-payment-from-to', {
fromState,
toState,
});
},
};
}
}

0 comments on commit 143e62f

Please sign in to comment.