Browse Source

feat(core): Improve customization of order process

This commit enables composition of multiple new custom order process states, and also makes it possible to inject providers into the transition hooks. Relates to #401

BREAKING CHANGE: The way custom Order states are defined has changed. The `VendureConfig.orderOptions.process` property now accepts an **array** of objects implementing the `CustomerOrderProcess` interface. This interface is more-or-less the same as the old `OrderProcessOptions` object, but the use of an array now allows better composition, and since `CustomerOrderProcess` inherits from `InjectableStrategy`, this means providers can now be injected and used in the custom order process logic.
Michael Bromley 5 years ago
parent
commit
0011ea9aaf
24 changed files with 509 additions and 100 deletions
  1. 198 0
      packages/core/e2e/order-process.e2e-spec.ts
  2. 16 0
      packages/core/e2e/shop-order.e2e-spec.ts
  3. 4 1
      packages/core/src/api/resolvers/shop/shop-order.resolver.ts
  4. 2 0
      packages/core/src/app.module.ts
  5. 0 0
      packages/core/src/common/finite-state-machine/finite-state-machine.spec.ts
  6. 15 5
      packages/core/src/common/finite-state-machine/finite-state-machine.ts
  7. 71 0
      packages/core/src/common/finite-state-machine/merge-transition-definitions.spec.ts
  8. 29 0
      packages/core/src/common/finite-state-machine/merge-transition-definitions.ts
  9. 60 0
      packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts
  10. 53 0
      packages/core/src/common/finite-state-machine/validate-transition-definition.ts
  11. 1 0
      packages/core/src/common/index.ts
  12. 1 1
      packages/core/src/config/default-config.ts
  13. 1 0
      packages/core/src/config/index.ts
  14. 10 0
      packages/core/src/config/order/custom-order-process.ts
  15. 1 1
      packages/core/src/config/payment-method/payment-method-handler.ts
  16. 7 40
      packages/core/src/config/vendure-config.ts
  17. 1 1
      packages/core/src/i18n/messages/en.json
  18. 32 41
      packages/core/src/service/helpers/order-state-machine/order-state-machine.ts
  19. 3 3
      packages/core/src/service/helpers/order-state-machine/order-state.ts
  20. 1 1
      packages/core/src/service/helpers/payment-state-machine/payment-state-machine.ts
  21. 1 1
      packages/core/src/service/helpers/payment-state-machine/payment-state.ts
  22. 1 1
      packages/core/src/service/helpers/refund-state-machine/refund-state-machine.ts
  23. 1 1
      packages/core/src/service/helpers/refund-state-machine/refund-state.ts
  24. 0 3
      packages/core/src/service/services/order.service.ts

+ 198 - 0
packages/core/e2e/order-process.e2e-spec.ts

@@ -0,0 +1,198 @@
+import { CustomOrderProcess, mergeConfig, OrderState } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import {
+    AddItemToOrder,
+    GetNextOrderStates,
+    SetCustomerForOrder,
+    TransitionToState,
+} from './graphql/generated-e2e-shop-types';
+import {
+    ADD_ITEM_TO_ORDER,
+    GET_NEXT_STATES,
+    SET_CUSTOMER,
+    TRANSITION_TO_STATE,
+} from './graphql/shop-definitions';
+
+type TestOrderState = OrderState | 'ValidatingCustomer';
+
+const initSpy = jest.fn();
+const transitionStartSpy = jest.fn();
+const transitionEndSpy = jest.fn();
+const transitionEndSpy2 = jest.fn();
+const transitionErrorSpy = jest.fn();
+
+describe('Order process', () => {
+    const VALIDATION_ERROR_MESSAGE = 'Customer must have a company email address';
+    const customOrderProcess: CustomOrderProcess<'ValidatingCustomer'> = {
+        init(injector) {
+            initSpy(injector.getConnection().name);
+        },
+        transitions: {
+            AddingItems: {
+                to: ['ValidatingCustomer'],
+                mergeStrategy: 'replace',
+            },
+            ValidatingCustomer: {
+                to: ['ArrangingPayment', 'AddingItems'],
+            },
+        },
+        onTransitionStart(fromState, toState, data) {
+            transitionStartSpy(fromState, toState, data);
+            if (toState === 'ValidatingCustomer') {
+                if (!data.order.customer) {
+                    return false;
+                }
+                if (!data.order.customer.emailAddress.includes('@company.com')) {
+                    return VALIDATION_ERROR_MESSAGE;
+                }
+            }
+        },
+        onTransitionEnd(fromState, toState, data) {
+            transitionEndSpy(fromState, toState, data);
+        },
+        onError(fromState, toState, message) {
+            transitionErrorSpy(fromState, toState, message);
+        },
+    };
+
+    const customOrderProcess2: CustomOrderProcess<'ValidatingCustomer'> = {
+        transitions: {
+            ValidatingCustomer: {
+                to: ['Cancelled'],
+            },
+        },
+        onTransitionEnd(fromState, toState, data) {
+            transitionEndSpy2(fromState, toState, data);
+        },
+    };
+
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            orderOptions: { process: [customOrderProcess, customOrderProcess2] },
+        }),
+    );
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('CustomOrderProcess is injectable', () => {
+        expect(initSpy).toHaveBeenCalledTimes(1);
+        expect(initSpy.mock.calls[0][0]).toBe('default');
+    });
+
+    it('replaced transition target', async () => {
+        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_1',
+            quantity: 1,
+        });
+
+        const { nextOrderStates } = await shopClient.query<GetNextOrderStates.Query>(GET_NEXT_STATES);
+
+        expect(nextOrderStates).toEqual(['ValidatingCustomer']);
+    });
+
+    it('custom onTransitionStart handler returning false', async () => {
+        transitionStartSpy.mockClear();
+        transitionEndSpy.mockClear();
+
+        const { transitionOrderToState } = await shopClient.query<
+            TransitionToState.Mutation,
+            TransitionToState.Variables
+        >(TRANSITION_TO_STATE, {
+            state: 'ValidatingCustomer',
+        });
+
+        expect(transitionStartSpy).toHaveBeenCalledTimes(1);
+        expect(transitionEndSpy).not.toHaveBeenCalled();
+        expect(transitionStartSpy.mock.calls[0].slice(0, 2)).toEqual(['AddingItems', 'ValidatingCustomer']);
+        expect(transitionOrderToState?.state).toBe('AddingItems');
+    });
+
+    it('custom onTransitionStart handler returning error message', async () => {
+        transitionStartSpy.mockClear();
+        transitionErrorSpy.mockClear();
+
+        await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(SET_CUSTOMER, {
+            input: {
+                firstName: 'Joe',
+                lastName: 'Test',
+                emailAddress: 'joetest@gmail.com',
+            },
+        });
+
+        try {
+            const { transitionOrderToState } = await shopClient.query<
+                TransitionToState.Mutation,
+                TransitionToState.Variables
+            >(TRANSITION_TO_STATE, {
+                state: 'ValidatingCustomer',
+            });
+            fail('Should have thrown');
+        } catch (e) {
+            expect(e.message).toContain(VALIDATION_ERROR_MESSAGE);
+        }
+
+        expect(transitionStartSpy).toHaveBeenCalledTimes(1);
+        expect(transitionErrorSpy).toHaveBeenCalledTimes(1);
+        expect(transitionEndSpy).not.toHaveBeenCalled();
+        expect(transitionErrorSpy.mock.calls[0]).toEqual([
+            'AddingItems',
+            'ValidatingCustomer',
+            VALIDATION_ERROR_MESSAGE,
+        ]);
+    });
+
+    it('custom onTransitionStart handler allows transition', async () => {
+        transitionEndSpy.mockClear();
+
+        await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(SET_CUSTOMER, {
+            input: {
+                firstName: 'Joe',
+                lastName: 'Test',
+                emailAddress: 'joetest@company.com',
+            },
+        });
+
+        const { transitionOrderToState } = await shopClient.query<
+            TransitionToState.Mutation,
+            TransitionToState.Variables
+        >(TRANSITION_TO_STATE, {
+            state: 'ValidatingCustomer',
+        });
+
+        expect(transitionEndSpy).toHaveBeenCalledTimes(1);
+        expect(transitionEndSpy.mock.calls[0].slice(0, 2)).toEqual(['AddingItems', 'ValidatingCustomer']);
+        expect(transitionOrderToState?.state).toBe('ValidatingCustomer');
+    });
+
+    it('composes multiple CustomOrderProcesses', async () => {
+        transitionEndSpy.mockClear();
+        transitionEndSpy2.mockClear();
+
+        const { nextOrderStates } = await shopClient.query<GetNextOrderStates.Query>(GET_NEXT_STATES);
+
+        expect(nextOrderStates).toEqual(['ArrangingPayment', 'AddingItems', 'Cancelled']);
+
+        await shopClient.query<TransitionToState.Mutation, TransitionToState.Variables>(TRANSITION_TO_STATE, {
+            state: 'Cancelled',
+        });
+
+        expect(transitionEndSpy.mock.calls[0].slice(0, 2)).toEqual(['ValidatingCustomer', 'Cancelled']);
+        expect(transitionEndSpy2.mock.calls[0].slice(0, 2)).toEqual(['ValidatingCustomer', 'Cancelled']);
+    });
+});

+ 16 - 0
packages/core/e2e/shop-order.e2e-spec.ts

@@ -594,6 +594,22 @@ describe('Shop orders', () => {
             expect(result2.activeOrder!.id).toBe(activeOrder.id);
         });
 
+        it(
+            'cannot setCustomerForOrder when already logged in',
+            assertThrowsWithMessage(async () => {
+                await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(
+                    SET_CUSTOMER,
+                    {
+                        input: {
+                            emailAddress: 'newperson@email.com',
+                            firstName: 'New',
+                            lastName: 'Person',
+                        },
+                    },
+                );
+            }, 'Cannot set a Customer for the Order when already logged in'),
+        );
+
         describe('shipping', () => {
             let shippingMethods: GetShippingMethods.EligibleShippingMethods[];
 

+ 4 - 1
packages/core/src/api/resolvers/shop/shop-order.resolver.ts

@@ -18,7 +18,7 @@ import {
 import { QueryCountriesArgs } from '@vendure/common/lib/generated-types';
 import ms from 'ms';
 
-import { ForbiddenError, InternalServerError } from '../../../common/error/errors';
+import { ForbiddenError, IllegalOperationError, InternalServerError } from '../../../common/error/errors';
 import { Translated } from '../../../common/types/locale-types';
 import { idsAreEqual } from '../../../common/utils';
 import { Country } from '../../../entity';
@@ -303,6 +303,9 @@ export class ShopOrderResolver {
     @Allow(Permission.Owner)
     async setCustomerForOrder(@Ctx() ctx: RequestContext, @Args() args: MutationSetCustomerForOrderArgs) {
         if (ctx.authorizedAsOwnerOnly) {
+            if (ctx.activeUserId) {
+                throw new IllegalOperationError('error.cannot-set-customer-for-order-when-logged-in');
+            }
             const sessionOrder = await this.getOrderFromContext(ctx);
             if (sessionOrder) {
                 const customer = await this.customerService.createOrUpdate(args.input, true);

+ 2 - 0
packages/core/src/app.module.ts

@@ -123,6 +123,7 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat
         const { jobQueueStrategy } = this.configService.jobQueueOptions;
         const { mergeStrategy, priceCalculationStrategy } = this.configService.orderOptions;
         const { entityIdStrategy } = this.configService;
+        const { process } = this.configService.orderOptions;
         return [
             ...adminAuthenticationStrategy,
             ...shopAuthenticationStrategy,
@@ -135,6 +136,7 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat
             mergeStrategy,
             entityIdStrategy,
             priceCalculationStrategy,
+            ...process,
         ];
     }
 

+ 0 - 0
packages/core/src/common/finite-state-machine.spec.ts → packages/core/src/common/finite-state-machine/finite-state-machine.spec.ts


+ 15 - 5
packages/core/src/common/finite-state-machine.ts → packages/core/src/common/finite-state-machine/finite-state-machine.ts

@@ -1,16 +1,23 @@
 import { Observable } from 'rxjs';
 
 /**
+ * @description
  * A type which is used to define all valid transitions and transition callbacks
+ *
+ * @docsCategory StateMachine
  */
-export type Transitions<T extends string> = {
-    [S in T]: {
-        to: T[];
-    }
+export type Transitions<State extends string, Target extends string = State> = {
+    [S in State]: {
+        to: Target[];
+        mergeStrategy?: 'merge' | 'replace';
+    };
 };
 
 /**
+ * @description
  * The config object used to instantiate a new FSM instance.
+ *
+ * @docsCategory StateMachine
  */
 export type StateMachineConfig<T extends string, Data = undefined> = {
     transitions: Transitions<T>;
@@ -28,7 +35,10 @@ export type StateMachineConfig<T extends string, Data = undefined> = {
 };
 
 /**
+ * @description
  * A simple type-safe finite state machine
+ *
+ * @docsCategory StateMachine
  */
 export class FSM<T extends string, Data = any> {
     private readonly _initialState: T;
@@ -65,7 +75,7 @@ export class FSM<T extends string, Data = any> {
             if (typeof this.config.onTransitionStart === 'function') {
                 const transitionResult = this.config.onTransitionStart(this._currentState, state, data);
                 const canTransition = await (transitionResult instanceof Observable
-                    ? await transitionResult.toPromise()
+                    ? transitionResult.toPromise()
                     : transitionResult);
                 if (canTransition === false) {
                     return;

+ 71 - 0
packages/core/src/common/finite-state-machine/merge-transition-definitions.spec.ts

@@ -0,0 +1,71 @@
+import { Transitions } from './finite-state-machine';
+import { mergeTransitionDefinitions } from './merge-transition-definitions';
+
+describe('FSM mergeTransitionDefinitions()', () => {
+    it('handles no b', () => {
+        const a: Transitions<'Start' | 'End'> = {
+            Start: { to: ['End'] },
+            End: { to: [] },
+        };
+        const result = mergeTransitionDefinitions(a);
+
+        expect(result).toEqual(a);
+    });
+
+    it('adding new state, merge by default', () => {
+        const a: Transitions<'Start' | 'End'> = {
+            Start: { to: ['End'] },
+            End: { to: [] },
+        };
+        const b: Transitions<'Start' | 'Cancelled'> = {
+            Start: { to: ['Cancelled'] },
+            Cancelled: { to: [] },
+        };
+        const result = mergeTransitionDefinitions(a, b);
+
+        expect(result).toEqual({
+            Start: { to: ['End', 'Cancelled'] },
+            End: { to: [] },
+            Cancelled: { to: [] },
+        });
+    });
+
+    it('adding new state, replace', () => {
+        const a: Transitions<'Start' | 'End'> = {
+            Start: { to: ['End'] },
+            End: { to: [] },
+        };
+        const b: Transitions<'Start' | 'Cancelled'> = {
+            Start: { to: ['Cancelled'], mergeStrategy: 'replace' },
+            Cancelled: { to: ['Start'] },
+        };
+        const result = mergeTransitionDefinitions(a, b);
+
+        expect(result).toEqual({
+            Start: { to: ['Cancelled'] },
+            End: { to: [] },
+            Cancelled: { to: ['Start'] },
+        });
+    });
+
+    it('is an idempotent, pure function', () => {
+        const a: Transitions<'Start' | 'End'> = {
+            Start: { to: ['End'] },
+            End: { to: [] },
+        };
+        const aCopy = { ...a };
+        const b: Transitions<'Start' | 'Cancelled'> = {
+            Start: { to: ['Cancelled'] },
+            Cancelled: { to: ['Start'] },
+        };
+        let result = mergeTransitionDefinitions(a, b);
+        result = mergeTransitionDefinitions(a, b);
+
+        expect(a).toEqual(aCopy);
+        expect(result).toEqual({
+            Start: { to: ['End', 'Cancelled'] },
+            End: { to: [] },
+            Cancelled: { to: ['Start'] },
+        });
+    });
+});

+ 29 - 0
packages/core/src/common/finite-state-machine/merge-transition-definitions.ts

@@ -0,0 +1,29 @@
+import { simpleDeepClone } from '@vendure/common/lib/simple-deep-clone';
+
+import { Transitions } from './finite-state-machine';
+
+/**
+ * Merges two state machine Transitions definitions.
+ */
+export function mergeTransitionDefinitions<A extends string, B extends string>(
+    a: Transitions<A>,
+    b?: Transitions<B>,
+): Transitions<A | B> {
+    if (!b) {
+        return a as Transitions<A | B>;
+    }
+    const merged: Transitions<A | B> = simpleDeepClone(a) as any;
+    for (const k of Object.keys(b)) {
+        const key = k as B;
+        if (merged.hasOwnProperty(key)) {
+            if (b[key].mergeStrategy === 'replace') {
+                merged[key].to = b[key].to;
+            } else {
+                merged[key].to = merged[key].to.concat(b[key].to);
+            }
+        } else {
+            merged[key] = b[key];
+        }
+    }
+    return merged;
+}

+ 60 - 0
packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts

@@ -0,0 +1,60 @@
+import { OrderState } from '../../service/helpers/order-state-machine/order-state';
+
+import { Transitions } from './finite-state-machine';
+import { validateTransitionDefinition } from './validate-transition-definition';
+
+describe('FSM validateTransitionDefinition()', () => {
+    it('valid definition', () => {
+        const valid: Transitions<'Start' | 'End'> = {
+            Start: { to: ['End'] },
+            End: { to: ['Start'] },
+        };
+
+        const result = validateTransitionDefinition(valid, 'Start');
+
+        expect(result.valid).toBe(true);
+    });
+
+    it('valid complex definition', () => {
+        const orderStateTransitions: Transitions<OrderState> = {
+            AddingItems: {
+                to: ['ArrangingPayment', 'Cancelled'],
+            },
+            ArrangingPayment: {
+                to: ['PaymentAuthorized', 'PaymentSettled', 'AddingItems', 'Cancelled'],
+            },
+            PaymentAuthorized: {
+                to: ['PaymentSettled', 'Cancelled'],
+            },
+            PaymentSettled: {
+                to: ['PartiallyFulfilled', 'Fulfilled', 'Cancelled'],
+            },
+            PartiallyFulfilled: {
+                to: ['Fulfilled', 'PartiallyFulfilled', 'Cancelled'],
+            },
+            Fulfilled: {
+                to: ['Cancelled'],
+            },
+            Cancelled: {
+                to: [],
+            },
+        };
+
+        const result = validateTransitionDefinition(orderStateTransitions, 'AddingItems');
+
+        expect(result.valid).toBe(true);
+    });
+
+    it('invalid - unreachable state', () => {
+        const valid: Transitions<'Start' | 'End' | 'Unreachable'> = {
+            Start: { to: ['End'] },
+            End: { to: ['Start'] },
+            Unreachable: { to: [] },
+        };
+
+        const result = validateTransitionDefinition(valid, 'Start');
+
+        expect(result.valid).toBe(false);
+        expect(result.error).toBe('The following states are unreachable: Unreachable');
+    });
+});

+ 53 - 0
packages/core/src/common/finite-state-machine/validate-transition-definition.ts

@@ -0,0 +1,53 @@
+import { Transitions } from './finite-state-machine';
+
+type ValidationResult = { reachable: boolean };
+
+/**
+ * This function validates a finite state machine transition graph to ensure
+ * that all states are reachable from the given initial state.
+ */
+export function validateTransitionDefinition<T extends string>(
+    transitions: Transitions<T>,
+    initialState: T,
+): { valid: boolean; error?: string } {
+    const states = Object.keys(transitions) as T[];
+    const result: { [State in T]: ValidationResult } = states.reduce((res, state) => {
+        return {
+            ...res,
+            [state]: { reachable: false },
+        };
+    }, {} as any);
+
+    // walk the state graph starting with the initialState and
+    // check whether all states are reachable.
+    function allStatesReached(): boolean {
+        return Object.values(result).every((r) => (r as ValidationResult).reachable);
+    }
+    function walkGraph(state: T) {
+        const candidates = transitions[state].to;
+        result[state].reachable = true;
+        if (allStatesReached()) {
+            return true;
+        }
+        for (const candidate of candidates) {
+            if (!result[candidate].reachable) {
+                walkGraph(candidate);
+            }
+        }
+    }
+    walkGraph(initialState);
+
+    if (!allStatesReached()) {
+        return {
+            valid: false,
+            error: `The following states are unreachable: ${Object.entries(result)
+                .filter(([s, v]) => !(v as ValidationResult).reachable)
+                .map(([s]) => s)
+                .join(', ')}`,
+        };
+    } else {
+        return {
+            valid: true,
+        };
+    }
+}

+ 1 - 0
packages/core/src/common/index.ts

@@ -1,3 +1,4 @@
+export * from './finite-state-machine/finite-state-machine';
 export * from './async-queue';
 export * from './error/errors';
 export * from './injector';

+ 1 - 1
packages/core/src/config/default-config.ts

@@ -100,7 +100,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         priceCalculationStrategy: new DefaultPriceCalculationStrategy(),
         mergeStrategy: new MergeOrdersStrategy(),
         checkoutMergeStrategy: new UseGuestStrategy(),
-        process: {},
+        process: [],
         generateOrderCode: () => generatePublicId(),
     },
     paymentOptions: {

+ 1 - 0
packages/core/src/config/index.ts

@@ -17,6 +17,7 @@ export * from './logger/default-logger';
 export * from './logger/noop-logger';
 export * from './logger/vendure-logger';
 export * from './merge-config';
+export * from './order/custom-order-process';
 export * from './order/order-merge-strategy';
 export * from './order/price-calculation-strategy';
 export * from './payment-method/example-payment-method-handler';

+ 10 - 0
packages/core/src/config/order/custom-order-process.ts

@@ -0,0 +1,10 @@
+import { StateMachineConfig, Transitions } from '../../common/finite-state-machine/finite-state-machine';
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+import { Order } from '../../entity/order/order.entity';
+import { OrderState, OrderTransitionData } from '../../service/helpers/order-state-machine/order-state';
+
+export interface CustomOrderProcess<State extends string>
+    extends InjectableStrategy,
+        Omit<StateMachineConfig<State & OrderState, OrderTransitionData>, 'transitions'> {
+    transitions?: Transitions<State, State | OrderState> & Partial<Transitions<OrderState | State>>;
+}

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

@@ -9,7 +9,7 @@ import {
     ConfigurableOperationDefOptions,
     LocalizedStringArray,
 } from '../../common/configurable-operation';
-import { StateMachineConfig } from '../../common/finite-state-machine';
+import { StateMachineConfig } from '../../common/finite-state-machine/finite-state-machine';
 import { Order } from '../../entity/order/order.entity';
 import { Payment, PaymentMetadata } from '../../entity/payment/payment.entity';
 import {

+ 7 - 40
packages/core/src/config/vendure-config.ts

@@ -8,7 +8,7 @@ import { Observable } from 'rxjs';
 import { ConnectionOptions } from 'typeorm';
 
 import { RequestContext } from '../api/common/request-context';
-import { Transitions } from '../common/finite-state-machine';
+import { Transitions } from '../common/finite-state-machine/finite-state-machine';
 import { Order } from '../entity/order/order.entity';
 import { OrderState } from '../service/helpers/order-state-machine/order-state';
 
@@ -21,6 +21,7 @@ import { CustomFields } from './custom-field/custom-field-types';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { JobQueueStrategy } from './job-queue/job-queue-strategy';
 import { VendureLogger } from './logger/vendure-logger';
+import { CustomOrderProcess } from './order/custom-order-process';
 import { OrderMergeStrategy } from './order/order-merge-strategy';
 import { PriceCalculationStrategy } from './order/price-calculation-strategy';
 import { PaymentMethodHandler } from './payment-method/payment-method-handler';
@@ -288,9 +289,12 @@ export interface OrderOptions {
     priceCalculationStrategy?: PriceCalculationStrategy;
     /**
      * @description
-     * Defines custom states and transition logic for the order process state machine.
+     * Allows the definition of custom states and transition logic for the order process state machine.
+     * Takes an array of objects implementing the {@link CustomOrderProcess} interface.
+     *
+     * @default []
      */
-    process?: OrderProcessOptions<string>;
+    process?: Array<CustomOrderProcess<string>>;
     /**
      * @description
      * Defines the strategy used to merge a guest Order and an existing Order when
@@ -320,43 +324,6 @@ export interface OrderOptions {
     generateOrderCode?: (ctx: RequestContext) => string | Promise<string>;
 }
 
-/**
- * @description
- * Defines custom states and transition logic for the order process state machine.
- *
- * @docsCategory orders
- * @docsPage OrderOptions
- */
-export interface OrderProcessOptions<T extends string> {
-    /**
-     * @description
-     * Define how the custom states fit in with the default order
-     * state transitions.
-     *
-     */
-    transitions?: Partial<Transitions<T | OrderState>>;
-    /**
-     * @description
-     * Define logic to run before a state tranition takes place. Returning
-     * false will prevent the transition from going ahead.
-     */
-    onTransitionStart?(
-        fromState: T,
-        toState: T,
-        data: { order: Order },
-    ): boolean | Promise<boolean> | Observable<boolean> | void;
-    /**
-     * @description
-     * Define logic to run after a state transition has taken place.
-     */
-    onTransitionEnd?(fromState: T, toState: T, data: { order: Order }): void;
-    /**
-     * @description
-     * Define a custom error handler function for transition errors.
-     */
-    onTransitionError?(fromState: T, toState: T, message?: string): void;
-}
-
 /**
  * @description
  * The AssetOptions define how assets (images and other files) are named and stored, and how preview images are generated.

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

@@ -10,6 +10,7 @@
     "cannot-move-collection-into-self": "Cannot move a Collection into itself",
     "cannot-remove-option-group-due-to-variants": "Cannot remove ProductOptionGroup \"{ code }\" as it is used by {count, plural, one {1 ProductVariant} other {# ProductVariants}}",
     "cannot-remove-tax-category-due-to-tax-rates": "Cannot remove TaxCategory \"{ name }\" as it is referenced by {count, plural, one {1 TaxRate} other {# TaxRates}}",
+    "cannot-set-customer-for-order-when-logged-in": "Cannot set a Customer for the Order when already logged in",
     "cannot-set-default-language-as-unavailable": "Cannot remove make language \"{ language }\" unavailable as it is used as the defaultLanguage by the channel \"{channelCode}\"",
     "cannot-transition-order-from-to": "Cannot transition Order from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-payment-from-to": "Cannot transition Payment from \"{ fromState }\" to \"{ toState }\"",
@@ -51,7 +52,6 @@
     "no-active-tax-zone": "The active tax zone could not be determined. Ensure a default tax zone is set for the current channel.",
     "no-search-plugin-configured": "No search plugin has been configured",
     "no-valid-channel-specified": "No valid channel was specified (ensure the 'vendure-token' header was specified in the request)",
-    "order-already-has-customer": "This Order already has a Customer associated with it",
     "order-contents-may-only-be-modified-in-addingitems-state": "Order contents may only be modified when in the \"AddingItems\" state",
     "order-does-not-contain-line-with-id": "This order does not contain an OrderLine with the id { id }",
     "order-item-quantity-must-be-positive": "{ quantity } is not a valid quantity for an OrderItem",

+ 32 - 41
packages/core/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -3,7 +3,13 @@ import { HistoryEntryType } from '@vendure/common/lib/generated-types';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { IllegalOperationError } from '../../../common/error/errors';
-import { FSM, StateMachineConfig, Transitions } from '../../../common/finite-state-machine';
+import {
+    FSM,
+    StateMachineConfig,
+    Transitions,
+} from '../../../common/finite-state-machine/finite-state-machine';
+import { mergeTransitionDefinitions } from '../../../common/finite-state-machine/merge-transition-definitions';
+import { validateTransitionDefinition } from '../../../common/finite-state-machine/validate-transition-definition';
 import { ConfigService } from '../../../config/config.service';
 import { Order } from '../../../entity/order/order.entity';
 import { HistoryService } from '../../services/history.service';
@@ -42,7 +48,7 @@ export class OrderStateMachine {
     async transition(ctx: RequestContext, order: Order, state: OrderState) {
         const fsm = new FSM(this.config, order.state);
         await fsm.transitionTo(state, { ctx, order });
-        order.state = state;
+        order.state = fsm.currentState;
     }
 
     /**
@@ -84,35 +90,42 @@ export class OrderStateMachine {
     }
 
     private initConfig(): StateMachineConfig<OrderState, OrderTransitionData> {
-        const {
-            transitions,
-            onTransitionStart,
-            onTransitionEnd,
-            onTransitionError,
-        } = this.configService.orderOptions.process;
+        const customProcesses = this.configService.orderOptions.process ?? [];
 
-        const allTransitions = this.mergeTransitionDefinitions(orderStateTransitions, transitions);
+        const allTransitions = customProcesses.reduce(
+            (transitions, process) =>
+                mergeTransitionDefinitions(transitions, process.transitions as Transitions<any>),
+            orderStateTransitions,
+        );
+
+        const validationResult = validateTransitionDefinition(allTransitions, 'AddingItems');
 
         return {
             transitions: allTransitions,
             onTransitionStart: async (fromState, toState, data) => {
-                if (typeof onTransitionStart === 'function') {
-                    const result = onTransitionStart(fromState, toState, data);
-                    if (result === false || typeof result === 'string') {
-                        return result;
+                for (const process of customProcesses) {
+                    if (typeof process.onTransitionStart === 'function') {
+                        const result = await process.onTransitionStart(fromState, toState, data);
+                        if (result === false || typeof result === 'string') {
+                            return result;
+                        }
                     }
                 }
                 return this.onTransitionStart(fromState, toState, data);
             },
-            onTransitionEnd: (fromState, toState, data) => {
-                if (typeof onTransitionEnd === 'function') {
-                    return onTransitionEnd(fromState, toState, data);
+            onTransitionEnd: async (fromState, toState, data) => {
+                for (const process of customProcesses) {
+                    if (typeof process.onTransitionEnd === 'function') {
+                        await process.onTransitionEnd(fromState, toState, data);
+                    }
                 }
-                return this.onTransitionEnd(fromState, toState, data);
+                await this.onTransitionEnd(fromState, toState, data);
             },
             onError: (fromState, toState, message) => {
-                if (typeof onTransitionError === 'function') {
-                    onTransitionError(fromState, toState, message);
+                for (const process of customProcesses) {
+                    if (typeof process.onError === 'function') {
+                        process.onError(fromState, toState, message);
+                    }
                 }
                 throw new IllegalOperationError(message || 'error.cannot-transition-order-from-to', {
                     fromState,
@@ -121,26 +134,4 @@ export class OrderStateMachine {
             },
         };
     }
-
-    /**
-     * Merge any custom transition definitions into the default transitions for the Order process.
-     */
-    private mergeTransitionDefinitions<T extends string>(
-        defaultTranstions: Transitions<T>,
-        customTranstitions?: any,
-    ): Transitions<T> {
-        if (!customTranstitions) {
-            return defaultTranstions;
-        }
-        const merged = defaultTranstions;
-        for (const k of Object.keys(customTranstitions)) {
-            const key = k as T;
-            if (merged.hasOwnProperty(key)) {
-                merged[key].to = merged[key].to.concat(customTranstitions[key].to);
-            } else {
-                merged[key] = customTranstitions[key];
-            }
-        }
-        return merged;
-    }
 }

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

@@ -1,11 +1,11 @@
 import { RequestContext } from '../../../api/common/request-context';
-import { Transitions } from '../../../common/finite-state-machine';
+import { Transitions } from '../../../common/finite-state-machine/finite-state-machine';
 import { Order } from '../../../entity/order/order.entity';
 
 /**
  * @description
- * These are the default states of the Order process. They can be augmented via
- * the `transtitions` property in the {@link OrderProcessOptions}.
+ * These are the default states of the Order process. They can be augmented and
+ * modified by using the {@link OrderOptions} `process` property.
  *
  * @docsCategory orders
  */

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

@@ -3,7 +3,7 @@ import { HistoryEntryType } from '@vendure/common/lib/generated-types';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { IllegalOperationError } from '../../../common/error/errors';
-import { FSM, StateMachineConfig } from '../../../common/finite-state-machine';
+import { FSM, StateMachineConfig } from '../../../common/finite-state-machine/finite-state-machine';
 import { ConfigService } from '../../../config/config.service';
 import { Order } from '../../../entity/order/order.entity';
 import { Payment } from '../../../entity/payment/payment.entity';

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

@@ -1,5 +1,5 @@
 import { RequestContext } from '../../../api/common/request-context';
-import { Transitions } from '../../../common/finite-state-machine';
+import { Transitions } from '../../../common/finite-state-machine/finite-state-machine';
 import { Order } from '../../../entity/order/order.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
 

+ 1 - 1
packages/core/src/service/helpers/refund-state-machine/refund-state-machine.ts

@@ -3,7 +3,7 @@ import { HistoryEntryType } from '@vendure/common/lib/generated-types';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { IllegalOperationError } from '../../../common/error/errors';
-import { FSM, StateMachineConfig } from '../../../common/finite-state-machine';
+import { FSM, StateMachineConfig } from '../../../common/finite-state-machine/finite-state-machine';
 import { ConfigService } from '../../../config/config.service';
 import { Order } from '../../../entity/order/order.entity';
 import { Refund } from '../../../entity/refund/refund.entity';

+ 1 - 1
packages/core/src/service/helpers/refund-state-machine/refund-state.ts

@@ -1,5 +1,5 @@
 import { RequestContext } from '../../../api/common/request-context';
-import { Transitions } from '../../../common/finite-state-machine';
+import { Transitions } from '../../../common/finite-state-machine/finite-state-machine';
 import { Order } from '../../../entity/order/order.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
 import { Refund } from '../../../entity/refund/refund.entity';

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

@@ -689,9 +689,6 @@ export class OrderService {
 
     async addCustomerToOrder(ctx: RequestContext, orderId: ID, customer: Customer): Promise<Order> {
         const order = await this.getOrderOrThrow(ctx, orderId);
-        if (order.customer && !idsAreEqual(order.customer.id, customer.id)) {
-            throw new IllegalOperationError(`error.order-already-has-customer`);
-        }
         order.customer = customer;
         await this.connection.getRepository(Order).save(order, { reload: false });
         // Check that any applied couponCodes are still valid now that