소스 검색

feat(core): Make Refund process configurable (#2942)

Tiis 1 년 전
부모
커밋
c8f1d62507

+ 6 - 1
packages/core/src/config/config.module.ts

@@ -14,7 +14,10 @@ import { ConfigService } from './config.service';
     exports: [ConfigService],
     exports: [ConfigService],
 })
 })
 export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdown {
 export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdown {
-    constructor(private configService: ConfigService, private moduleRef: ModuleRef) {}
+    constructor(
+        private configService: ConfigService,
+        private moduleRef: ModuleRef,
+    ) {}
 
 
     async onApplicationBootstrap() {
     async onApplicationBootstrap() {
         await this.initInjectableStrategies();
         await this.initInjectableStrategies();
@@ -106,6 +109,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
         const { entityIdStrategy } = this.configService.entityOptions;
         const { entityIdStrategy } = this.configService.entityOptions;
         const { healthChecks, errorHandlers } = this.configService.systemOptions;
         const { healthChecks, errorHandlers } = this.configService.systemOptions;
         const { assetImportStrategy } = this.configService.importExportOptions;
         const { assetImportStrategy } = this.configService.importExportOptions;
+        const { refundProcess: refundProcess } = this.configService.paymentOptions;
         return [
         return [
             ...adminAuthenticationStrategy,
             ...adminAuthenticationStrategy,
             ...shopAuthenticationStrategy,
             ...shopAuthenticationStrategy,
@@ -145,6 +149,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             stockLocationStrategy,
             stockLocationStrategy,
             productVariantPriceSelectionStrategy,
             productVariantPriceSelectionStrategy,
             guestCheckoutStrategy,
             guestCheckoutStrategy,
+            ...refundProcess,
         ];
         ];
     }
     }
 
 

+ 2 - 0
packages/core/src/config/default-config.ts

@@ -43,6 +43,7 @@ import { DefaultOrderCodeStrategy } from './order/order-code-strategy';
 import { UseGuestStrategy } from './order/use-guest-strategy';
 import { UseGuestStrategy } from './order/use-guest-strategy';
 import { defaultPaymentProcess } from './payment/default-payment-process';
 import { defaultPaymentProcess } from './payment/default-payment-process';
 import { defaultPromotionActions, defaultPromotionConditions } from './promotion';
 import { defaultPromotionActions, defaultPromotionConditions } from './promotion';
+import { defaultRefundProcess } from './refund/default-refund-process';
 import { InMemorySessionCacheStrategy } from './session-cache/in-memory-session-cache-strategy';
 import { InMemorySessionCacheStrategy } from './session-cache/in-memory-session-cache-strategy';
 import { defaultShippingCalculator } from './shipping-method/default-shipping-calculator';
 import { defaultShippingCalculator } from './shipping-method/default-shipping-calculator';
 import { defaultShippingEligibilityChecker } from './shipping-method/default-shipping-eligibility-checker';
 import { defaultShippingEligibilityChecker } from './shipping-method/default-shipping-eligibility-checker';
@@ -170,6 +171,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         paymentMethodHandlers: [],
         paymentMethodHandlers: [],
         customPaymentProcess: [],
         customPaymentProcess: [],
         process: [defaultPaymentProcess],
         process: [defaultPaymentProcess],
+        refundProcess: [defaultRefundProcess],
     },
     },
     taxOptions: {
     taxOptions: {
         taxZoneStrategy: new DefaultTaxZoneStrategy(),
         taxZoneStrategy: new DefaultTaxZoneStrategy(),

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

@@ -89,3 +89,4 @@ export * from './tax/default-tax-zone-strategy';
 export * from './tax/tax-line-calculation-strategy';
 export * from './tax/tax-line-calculation-strategy';
 export * from './tax/tax-zone-strategy';
 export * from './tax/tax-zone-strategy';
 export * from './vendure-config';
 export * from './vendure-config';
+export * from './refund/default-refund-process';

+ 53 - 0
packages/core/src/config/refund/default-refund-process.ts

@@ -0,0 +1,53 @@
+import { HistoryEntryType } from '@vendure/common/lib/generated-types';
+
+import { RefundState } from '../../service/helpers/refund-state-machine/refund-state';
+
+import { RefundProcess } from './refund-process';
+
+let configService: import('../config.service').ConfigService;
+let historyService: import('../../service/index').HistoryService;
+
+/**
+ * @description
+ * The default {@link RefundProcess}.
+ *
+ * @docsCategory refund
+ */
+export const defaultRefundProcess: RefundProcess<RefundState> = {
+    transitions: {
+        Pending: {
+            to: ['Settled', 'Failed'],
+        },
+        Settled: {
+            to: [],
+        },
+        Failed: {
+            to: [],
+        },
+    },
+    async init(injector) {
+        const ConfigService = await import('../config.service.js').then(m => m.ConfigService);
+        const HistoryService = await import('../../service/index.js').then(m => m.HistoryService);
+        configService = injector.get(ConfigService);
+        historyService = injector.get(HistoryService);
+    },
+    onTransitionStart: async (fromState, toState, data) => {
+        return true;
+    },
+    onTransitionEnd: async (fromState, toState, data) => {
+        if (!historyService) {
+            throw new Error('HistoryService has not been initialized');
+        }
+        await historyService.createHistoryEntryForOrder({
+            ctx: data.ctx,
+            orderId: data.order.id,
+            type: HistoryEntryType.ORDER_REFUND_TRANSITION,
+            data: {
+                refundId: data.refund.id,
+                from: fromState,
+                to: toState,
+                reason: data.refund.reason,
+            },
+        });
+    },
+};

+ 41 - 0
packages/core/src/config/refund/refund-process.ts

@@ -0,0 +1,41 @@
+import {
+    OnTransitionEndFn,
+    OnTransitionErrorFn,
+    OnTransitionStartFn,
+    Transitions,
+} from '../../common/finite-state-machine/types';
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+import {
+    CustomRefundStates,
+    RefundState,
+    RefundTransitionData,
+} from '../../service/helpers/refund-state-machine/refund-state';
+
+/**
+ * @description
+ * A RefundProcess is used to define the way the refund process works as in: what states a Refund can be
+ * in, and how it may transition from one state to another. Using the `onTransitionStart()` hook, a
+ * RefundProcess can perform checks before allowing a state transition to occur, and the `onTransitionEnd()`
+ * hook allows logic to be executed after a state change.
+ *
+ * For detailed description of the interface members, see the {@link StateMachineConfig} docs.
+ *
+ * @docsCategory refund
+ */
+export interface RefundProcess<State extends keyof CustomRefundStates | string> extends InjectableStrategy {
+    transitions?: Transitions<State, State | RefundState> & Partial<Transitions<RefundState | State>>;
+    onTransitionStart?: OnTransitionStartFn<State | RefundState, RefundTransitionData>;
+    onTransitionEnd?: OnTransitionEndFn<State | RefundState, RefundTransitionData>;
+    onTransitionError?: OnTransitionErrorFn<State | RefundState>;
+}
+
+/**
+ * @description
+ * Used to define extensions to or modifications of the default refund process.
+ *
+ * For detailed description of the interface members, see the {@link StateMachineConfig} docs.
+ *
+ * @deprecated Use RefundProcess
+ */
+export interface CustomRefundProcess<State extends keyof CustomRefundStates | string>
+    extends RefundProcess<State> {}

+ 9 - 0
packages/core/src/config/vendure-config.ts

@@ -48,6 +48,7 @@ import { PaymentMethodHandler } from './payment/payment-method-handler';
 import { PaymentProcess } from './payment/payment-process';
 import { PaymentProcess } from './payment/payment-process';
 import { PromotionAction } from './promotion/promotion-action';
 import { PromotionAction } from './promotion/promotion-action';
 import { PromotionCondition } from './promotion/promotion-condition';
 import { PromotionCondition } from './promotion/promotion-condition';
+import { RefundProcess } from './refund/refund-process';
 import { SessionCacheStrategy } from './session-cache/session-cache-strategy';
 import { SessionCacheStrategy } from './session-cache/session-cache-strategy';
 import { ShippingCalculator } from './shipping-method/shipping-calculator';
 import { ShippingCalculator } from './shipping-method/shipping-calculator';
 import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker';
 import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker';
@@ -848,6 +849,14 @@ export interface PaymentOptions {
      * @since 2.0.0
      * @since 2.0.0
      */
      */
     process?: Array<PaymentProcess<any>>;
     process?: Array<PaymentProcess<any>>;
+    /**
+     * @description
+     * Allows the definition of custom states and transition logic for the refund process state machine.
+     * Takes an array of objects implementing the {@link RefundProcess} interface.
+     *
+     * @default defaultRefundProcess
+     */
+    refundProcess?: Array<RefundProcess<any>>;
 }
 }
 
 
 /**
 /**

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

@@ -1,46 +1,31 @@
 import { Injectable } from '@nestjs/common';
 import { Injectable } from '@nestjs/common';
-import { HistoryEntryType } from '@vendure/common/lib/generated-types';
 
 
 import { RequestContext } from '../../../api/common/request-context';
 import { RequestContext } from '../../../api/common/request-context';
 import { IllegalOperationError } from '../../../common/error/errors';
 import { IllegalOperationError } from '../../../common/error/errors';
 import { FSM } from '../../../common/finite-state-machine/finite-state-machine';
 import { FSM } from '../../../common/finite-state-machine/finite-state-machine';
-import { StateMachineConfig } from '../../../common/finite-state-machine/types';
+import { mergeTransitionDefinitions } from '../../../common/finite-state-machine/merge-transition-definitions';
+import { StateMachineConfig, Transitions } from '../../../common/finite-state-machine/types';
+import { validateTransitionDefinition } from '../../../common/finite-state-machine/validate-transition-definition';
+import { awaitPromiseOrObservable } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
 import { ConfigService } from '../../../config/config.service';
+import { Logger } from '../../../config/logger/vendure-logger';
 import { Order } from '../../../entity/order/order.entity';
 import { Order } from '../../../entity/order/order.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
-import { HistoryService } from '../../services/history.service';
 
 
-import { RefundState, refundStateTransitions, RefundTransitionData } from './refund-state';
+import { RefundState, RefundTransitionData } from './refund-state';
 
 
 @Injectable()
 @Injectable()
 export class RefundStateMachine {
 export class RefundStateMachine {
-    private readonly config: StateMachineConfig<RefundState, RefundTransitionData> = {
-        transitions: refundStateTransitions,
-        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_REFUND_TRANSITION,
-                data: {
-                    refundId: data.refund.id,
-                    from: fromState,
-                    to: toState,
-                    reason: data.refund.reason,
-                },
-            });
-        },
-        onError: (fromState, toState, message) => {
-            throw new IllegalOperationError(message || 'error.cannot-transition-refund-from-to', {
-                fromState,
-                toState,
-            });
-        },
-    };
-
-    constructor(private configService: ConfigService, private historyService: HistoryService) {}
+    private readonly config: StateMachineConfig<RefundState, RefundTransitionData>;
+    private readonly initialState: RefundState = 'Pending';
+
+    constructor(private configService: ConfigService) {
+        this.config = this.initConfig();
+    }
+
+    getInitialState(): RefundState {
+        return this.initialState;
+    }
 
 
     getNextStates(refund: Refund): readonly RefundState[] {
     getNextStates(refund: Refund): readonly RefundState[] {
         const fsm = new FSM(this.config, refund.state);
         const fsm = new FSM(this.config, refund.state);
@@ -53,4 +38,58 @@ export class RefundStateMachine {
         refund.state = state;
         refund.state = state;
         return result;
         return result;
     }
     }
+
+    private initConfig(): StateMachineConfig<RefundState, RefundTransitionData> {
+        const processes = [...(this.configService.paymentOptions.refundProcess ?? [])];
+        const allTransitions = processes.reduce(
+            (transitions, process) =>
+                mergeTransitionDefinitions(transitions, process.transitions as Transitions<any>),
+            {} as Transitions<RefundState>,
+        );
+
+        const validationResult = validateTransitionDefinition(allTransitions, this.initialState);
+        if (!validationResult.valid && validationResult.error) {
+            Logger.error(`The refund process has an invalid configuration:`);
+            throw new Error(validationResult.error);
+        }
+        if (validationResult.valid && validationResult.error) {
+            Logger.warn(`Refund process: ${validationResult.error}`);
+        }
+
+        return {
+            transitions: allTransitions,
+            onTransitionStart: async (fromState, toState, data) => {
+                for (const process of processes) {
+                    if (typeof process.onTransitionStart === 'function') {
+                        const result = await awaitPromiseOrObservable(
+                            process.onTransitionStart(fromState, toState, data),
+                        );
+                        if (result === false || typeof result === 'string') {
+                            return result;
+                        }
+                    }
+                }
+            },
+            onTransitionEnd: async (fromState, toState, data) => {
+                for (const process of processes) {
+                    if (typeof process.onTransitionEnd === 'function') {
+                        await awaitPromiseOrObservable(process.onTransitionEnd(fromState, toState, data));
+                    }
+                }
+            },
+            onError: async (fromState, toState, message) => {
+                for (const process of processes) {
+                    if (typeof process.onTransitionError === 'function') {
+                        await awaitPromiseOrObservable(
+                            process.onTransitionError(fromState, toState, message),
+                        );
+                    }
+                }
+                throw new IllegalOperationError(message || 'error.cannot-transition-refund-from-to', {
+                    fromState,
+                    toState,
+                });
+            },
+        };
+    }
 }
 }

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

@@ -1,28 +1,30 @@
 import { RequestContext } from '../../../api/common/request-context';
 import { RequestContext } from '../../../api/common/request-context';
-import { Transitions } from '../../../common/finite-state-machine/types';
 import { Order } from '../../../entity/order/order.entity';
 import { Order } from '../../../entity/order/order.entity';
-import { Payment } from '../../../entity/payment/payment.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
 
 
 /**
 /**
  * @description
  * @description
- * These are the default states of the refund process.
+ * An interface to extend standard {@link RefundState}.
  *
  *
- * @docsCategory payment
+ * @deprecated use RefundStates
+ */
+export interface CustomRefundStates {}
+
+/**
+ * @description
+ * An interface to extend standard {@link RefundState}.
+ *
+ * @docsCategory refund
  */
  */
-export type RefundState = 'Pending' | 'Settled' | 'Failed';
+export interface RefundStates {}
 
 
-export const refundStateTransitions: Transitions<RefundState> = {
-    Pending: {
-        to: ['Settled', 'Failed'],
-    },
-    Settled: {
-        to: [],
-    },
-    Failed: {
-        to: [],
-    },
-};
+/**
+ * @description
+ * These are the default states of the refund process.
+ *
+ * @docsCategory refund
+ */
+export type RefundState = 'Pending' | 'Settled' | 'Failed' | keyof CustomRefundStates | keyof RefundStates;
 
 
 /**
 /**
  * @description
  * @description

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

@@ -53,6 +53,7 @@ import {
     CancelPaymentError,
     CancelPaymentError,
     EmptyOrderLineSelectionError,
     EmptyOrderLineSelectionError,
     FulfillmentStateTransitionError,
     FulfillmentStateTransitionError,
+    RefundStateTransitionError,
     InsufficientStockOnHandError,
     InsufficientStockOnHandError,
     ItemsAlreadyFulfilledError,
     ItemsAlreadyFulfilledError,
     ManualPaymentStateError,
     ManualPaymentStateError,
@@ -109,6 +110,7 @@ import { OrderModifier } from '../helpers/order-modifier/order-modifier';
 import { OrderState } from '../helpers/order-state-machine/order-state';
 import { OrderState } from '../helpers/order-state-machine/order-state';
 import { OrderStateMachine } from '../helpers/order-state-machine/order-state-machine';
 import { OrderStateMachine } from '../helpers/order-state-machine/order-state-machine';
 import { PaymentState } from '../helpers/payment-state-machine/payment-state';
 import { PaymentState } from '../helpers/payment-state-machine/payment-state';
+import { RefundState } from '../helpers/refund-state-machine/refund-state';
 import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state-machine';
 import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state-machine';
 import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calculator';
 import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calculator';
 import { TranslatorService } from '../helpers/translator/translator.service';
 import { TranslatorService } from '../helpers/translator/translator.service';
@@ -988,6 +990,38 @@ export class OrderService {
         return result.fulfillment;
         return result.fulfillment;
     }
     }
 
 
+    /**
+     * @description
+     * Transitions a Refund to the given state
+     */
+    async transitionRefundToState(
+        ctx: RequestContext,
+        refundId: ID,
+        state: RefundState,
+        transactionId?: string,
+    ): Promise<Refund | RefundStateTransitionError> {
+        const refund = await this.connection.getEntityOrThrow(ctx, Refund, refundId, {
+            relations: ['payment', 'payment.order'],
+        });
+        if (transactionId && refund.transactionId !== transactionId) {
+            refund.transactionId = transactionId;
+        }
+        const fromState = refund.state;
+        const toState = state;
+        const { finalize } = await this.refundStateMachine.transition(
+            ctx,
+            refund.payment.order,
+            refund,
+            toState,
+        );
+        await this.connection.getRepository(ctx, Refund).save(refund);
+        await finalize();
+        await this.eventBus.publish(
+            new RefundStateTransitionEvent(fromState, toState, ctx, refund, refund.payment.order),
+        );
+        return refund;
+    }
+
     /**
     /**
      * @description
      * @description
      * Allows the Order to be modified, which allows several aspects of the Order to be changed:
      * Allows the Order to be modified, which allows several aspects of the Order to be changed: