diff --git a/packages/core/e2e/order-process.e2e-spec.ts b/packages/core/e2e/order-process.e2e-spec.ts new file mode 100644 index 0000000000..edf20dac76 --- /dev/null +++ b/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(ADD_ITEM_TO_ORDER, { + productVariantId: 'T_1', + quantity: 1, + }); + + const { nextOrderStates } = await shopClient.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(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(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(GET_NEXT_STATES); + + expect(nextOrderStates).toEqual(['ArrangingPayment', 'AddingItems', 'Cancelled']); + + await shopClient.query(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']); + }); +}); diff --git a/packages/core/e2e/shop-order.e2e-spec.ts b/packages/core/e2e/shop-order.e2e-spec.ts index 4a051cdc66..19f574d226 100644 --- a/packages/core/e2e/shop-order.e2e-spec.ts +++ b/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( + 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[]; diff --git a/packages/core/src/api/resolvers/shop/shop-order.resolver.ts b/packages/core/src/api/resolvers/shop/shop-order.resolver.ts index 28423787b7..78c7b47e20 100644 --- a/packages/core/src/api/resolvers/shop/shop-order.resolver.ts +++ b/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); diff --git a/packages/core/src/app.module.ts b/packages/core/src/app.module.ts index f70d409b8a..02cc57afc7 100644 --- a/packages/core/src/app.module.ts +++ b/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, ]; } diff --git a/packages/core/src/common/finite-state-machine.spec.ts b/packages/core/src/common/finite-state-machine/finite-state-machine.spec.ts similarity index 100% rename from packages/core/src/common/finite-state-machine.spec.ts rename to packages/core/src/common/finite-state-machine/finite-state-machine.spec.ts diff --git a/packages/core/src/common/finite-state-machine.ts b/packages/core/src/common/finite-state-machine/finite-state-machine.ts similarity index 91% rename from packages/core/src/common/finite-state-machine.ts rename to packages/core/src/common/finite-state-machine/finite-state-machine.ts index f447a9e1d4..08c379d3ad 100644 --- a/packages/core/src/common/finite-state-machine.ts +++ b/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 = { - [S in T]: { - to: T[]; - } +export type Transitions = { + [S in State]: { + to: Target[]; + mergeStrategy?: 'merge' | 'replace'; + }; }; /** + * @description * The config object used to instantiate a new FSM instance. + * + * @docsCategory StateMachine */ export type StateMachineConfig = { transitions: Transitions; @@ -28,7 +35,10 @@ export type StateMachineConfig = { }; /** + * @description * A simple type-safe finite state machine + * + * @docsCategory StateMachine */ export class FSM { private readonly _initialState: T; @@ -65,7 +75,7 @@ export class FSM { 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; diff --git a/packages/core/src/common/finite-state-machine/merge-transition-definitions.spec.ts b/packages/core/src/common/finite-state-machine/merge-transition-definitions.spec.ts new file mode 100644 index 0000000000..57040f6198 --- /dev/null +++ b/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'] }, + }); + }); +}); diff --git a/packages/core/src/common/finite-state-machine/merge-transition-definitions.ts b/packages/core/src/common/finite-state-machine/merge-transition-definitions.ts new file mode 100644 index 0000000000..7dfa69d4e8 --- /dev/null +++ b/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: Transitions, + b?: Transitions, +): Transitions { + if (!b) { + return a as Transitions; + } + const merged: Transitions = 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; +} diff --git a/packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts b/packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts new file mode 100644 index 0000000000..79ae333a6a --- /dev/null +++ b/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 = { + 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'); + }); +}); diff --git a/packages/core/src/common/finite-state-machine/validate-transition-definition.ts b/packages/core/src/common/finite-state-machine/validate-transition-definition.ts new file mode 100644 index 0000000000..78cceb08d1 --- /dev/null +++ b/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( + transitions: Transitions, + 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, + }; + } +} diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index fe1ed5c38c..6b0600e17d 100644 --- a/packages/core/src/common/index.ts +++ b/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'; diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index 6bddc0f56c..70e37befa6 100644 --- a/packages/core/src/config/default-config.ts +++ b/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: { diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 3bf9b8f37e..b9f202bff2 100644 --- a/packages/core/src/config/index.ts +++ b/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'; diff --git a/packages/core/src/config/order/custom-order-process.ts b/packages/core/src/config/order/custom-order-process.ts new file mode 100644 index 0000000000..81aaf97056 --- /dev/null +++ b/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 + extends InjectableStrategy, + Omit, 'transitions'> { + transitions?: Transitions & Partial>; +} 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 f48fa37af1..459bb5b860 100644 --- a/packages/core/src/config/payment-method/payment-method-handler.ts +++ b/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 { diff --git a/packages/core/src/config/vendure-config.ts b/packages/core/src/config/vendure-config.ts index 68b5d8c5a8..80e5feb579 100644 --- a/packages/core/src/config/vendure-config.ts +++ b/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; + process?: Array>; /** * @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; } -/** - * @description - * Defines custom states and transition logic for the order process state machine. - * - * @docsCategory orders - * @docsPage OrderOptions - */ -export interface OrderProcessOptions { - /** - * @description - * Define how the custom states fit in with the default order - * state transitions. - * - */ - transitions?: Partial>; - /** - * @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 | Observable | 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. diff --git a/packages/core/src/i18n/messages/en.json b/packages/core/src/i18n/messages/en.json index 1d122b12e8..75cf16fdc4 100644 --- a/packages/core/src/i18n/messages/en.json +++ b/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", diff --git a/packages/core/src/service/helpers/order-state-machine/order-state-machine.ts b/packages/core/src/service/helpers/order-state-machine/order-state-machine.ts index fccf03dfe6..de12f9e7df 100644 --- a/packages/core/src/service/helpers/order-state-machine/order-state-machine.ts +++ b/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 { - 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), + 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( - defaultTranstions: Transitions, - customTranstitions?: any, - ): Transitions { - 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; - } } diff --git a/packages/core/src/service/helpers/order-state-machine/order-state.ts b/packages/core/src/service/helpers/order-state-machine/order-state.ts index 2f2850c49d..f42caabc6d 100644 --- a/packages/core/src/service/helpers/order-state-machine/order-state.ts +++ b/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 */ diff --git a/packages/core/src/service/helpers/payment-state-machine/payment-state-machine.ts b/packages/core/src/service/helpers/payment-state-machine/payment-state-machine.ts index 3250848f60..5dc8385e65 100644 --- a/packages/core/src/service/helpers/payment-state-machine/payment-state-machine.ts +++ b/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'; diff --git a/packages/core/src/service/helpers/payment-state-machine/payment-state.ts b/packages/core/src/service/helpers/payment-state-machine/payment-state.ts index 039979f625..2aac371a36 100644 --- a/packages/core/src/service/helpers/payment-state-machine/payment-state.ts +++ b/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'; diff --git a/packages/core/src/service/helpers/refund-state-machine/refund-state-machine.ts b/packages/core/src/service/helpers/refund-state-machine/refund-state-machine.ts index e513be9a69..10d7974cfa 100644 --- a/packages/core/src/service/helpers/refund-state-machine/refund-state-machine.ts +++ b/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'; diff --git a/packages/core/src/service/helpers/refund-state-machine/refund-state.ts b/packages/core/src/service/helpers/refund-state-machine/refund-state.ts index bdeb82b809..3be8da13a2 100644 --- a/packages/core/src/service/helpers/refund-state-machine/refund-state.ts +++ b/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'; diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index 7289e4c9f7..bfae846de7 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/packages/core/src/service/services/order.service.ts @@ -689,9 +689,6 @@ export class OrderService { async addCustomerToOrder(ctx: RequestContext, orderId: ID, customer: Customer): Promise { 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