From c8f1d62507417da8e6a8f4ab1164e3931abb8640 Mon Sep 17 00:00:00 2001 From: Tiis Date: Tue, 16 Jul 2024 20:30:29 +0900 Subject: [PATCH] feat(core): Make Refund process configurable (#2942) --- packages/core/src/config/config.module.ts | 7 +- packages/core/src/config/default-config.ts | 2 + packages/core/src/config/index.ts | 1 + .../config/refund/default-refund-process.ts | 53 +++++++++ .../core/src/config/refund/refund-process.ts | 41 +++++++ packages/core/src/config/vendure-config.ts | 9 ++ .../refund-state-machine.ts | 101 ++++++++++++------ .../refund-state-machine/refund-state.ts | 34 +++--- .../src/service/services/order.service.ts | 34 ++++++ 9 files changed, 234 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/config/refund/default-refund-process.ts create mode 100644 packages/core/src/config/refund/refund-process.ts diff --git a/packages/core/src/config/config.module.ts b/packages/core/src/config/config.module.ts index 25088de477..8c7cb88feb 100644 --- a/packages/core/src/config/config.module.ts +++ b/packages/core/src/config/config.module.ts @@ -14,7 +14,10 @@ import { ConfigService } from './config.service'; exports: [ConfigService], }) export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdown { - constructor(private configService: ConfigService, private moduleRef: ModuleRef) {} + constructor( + private configService: ConfigService, + private moduleRef: ModuleRef, + ) {} async onApplicationBootstrap() { await this.initInjectableStrategies(); @@ -106,6 +109,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo const { entityIdStrategy } = this.configService.entityOptions; const { healthChecks, errorHandlers } = this.configService.systemOptions; const { assetImportStrategy } = this.configService.importExportOptions; + const { refundProcess: refundProcess } = this.configService.paymentOptions; return [ ...adminAuthenticationStrategy, ...shopAuthenticationStrategy, @@ -145,6 +149,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo stockLocationStrategy, productVariantPriceSelectionStrategy, guestCheckoutStrategy, + ...refundProcess, ]; } diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index d55f5c1ec2..e7b5f5b6dc 100644 --- a/packages/core/src/config/default-config.ts +++ b/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 { defaultPaymentProcess } from './payment/default-payment-process'; import { defaultPromotionActions, defaultPromotionConditions } from './promotion'; +import { defaultRefundProcess } from './refund/default-refund-process'; import { InMemorySessionCacheStrategy } from './session-cache/in-memory-session-cache-strategy'; import { defaultShippingCalculator } from './shipping-method/default-shipping-calculator'; import { defaultShippingEligibilityChecker } from './shipping-method/default-shipping-eligibility-checker'; @@ -170,6 +171,7 @@ export const defaultConfig: RuntimeVendureConfig = { paymentMethodHandlers: [], customPaymentProcess: [], process: [defaultPaymentProcess], + refundProcess: [defaultRefundProcess], }, taxOptions: { taxZoneStrategy: new DefaultTaxZoneStrategy(), diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index d74e731676..52d797a280 100644 --- a/packages/core/src/config/index.ts +++ b/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-zone-strategy'; export * from './vendure-config'; +export * from './refund/default-refund-process'; diff --git a/packages/core/src/config/refund/default-refund-process.ts b/packages/core/src/config/refund/default-refund-process.ts new file mode 100644 index 0000000000..d309373d34 --- /dev/null +++ b/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 = { + 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, + }, + }); + }, +}; diff --git a/packages/core/src/config/refund/refund-process.ts b/packages/core/src/config/refund/refund-process.ts new file mode 100644 index 0000000000..f1c7c307ab --- /dev/null +++ b/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 extends InjectableStrategy { + transitions?: Transitions & Partial>; + onTransitionStart?: OnTransitionStartFn; + onTransitionEnd?: OnTransitionEndFn; + onTransitionError?: OnTransitionErrorFn; +} + +/** + * @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 + extends RefundProcess {} diff --git a/packages/core/src/config/vendure-config.ts b/packages/core/src/config/vendure-config.ts index 18c9503834..3ce74aadb4 100644 --- a/packages/core/src/config/vendure-config.ts +++ b/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 { PromotionAction } from './promotion/promotion-action'; import { PromotionCondition } from './promotion/promotion-condition'; +import { RefundProcess } from './refund/refund-process'; import { SessionCacheStrategy } from './session-cache/session-cache-strategy'; import { ShippingCalculator } from './shipping-method/shipping-calculator'; import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker'; @@ -848,6 +849,14 @@ export interface PaymentOptions { * @since 2.0.0 */ process?: Array>; + /** + * @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>; } /** 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 6b6cf489ba..1d62a7c7dc 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 @@ -1,46 +1,31 @@ import { Injectable } from '@nestjs/common'; -import { HistoryEntryType } from '@vendure/common/lib/generated-types'; import { RequestContext } from '../../../api/common/request-context'; import { IllegalOperationError } from '../../../common/error/errors'; import { FSM } from '../../../common/finite-state-machine/finite-state-machine'; -import { StateMachineConfig } from '../../../common/finite-state-machine/types'; +import { 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 { Logger } from '../../../config/logger/vendure-logger'; import { Order } from '../../../entity/order/order.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() export class RefundStateMachine { - private readonly config: StateMachineConfig = { - 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; + private readonly initialState: RefundState = 'Pending'; + + constructor(private configService: ConfigService) { + this.config = this.initConfig(); + } + + getInitialState(): RefundState { + return this.initialState; + } getNextStates(refund: Refund): readonly RefundState[] { const fsm = new FSM(this.config, refund.state); @@ -53,4 +38,58 @@ export class RefundStateMachine { refund.state = state; return result; } + + private initConfig(): StateMachineConfig { + const processes = [...(this.configService.paymentOptions.refundProcess ?? [])]; + const allTransitions = processes.reduce( + (transitions, process) => + mergeTransitionDefinitions(transitions, process.transitions as Transitions), + {} as Transitions, + ); + + 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, + }); + }, + }; + } } 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 56b5a210f4..987071e521 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,28 +1,30 @@ import { RequestContext } from '../../../api/common/request-context'; -import { Transitions } from '../../../common/finite-state-machine/types'; import { Order } from '../../../entity/order/order.entity'; -import { Payment } from '../../../entity/payment/payment.entity'; import { Refund } from '../../../entity/refund/refund.entity'; /** * @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 = { - 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 diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index a64b6a5282..d3afe58ad8 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/packages/core/src/service/services/order.service.ts @@ -53,6 +53,7 @@ import { CancelPaymentError, EmptyOrderLineSelectionError, FulfillmentStateTransitionError, + RefundStateTransitionError, InsufficientStockOnHandError, ItemsAlreadyFulfilledError, ManualPaymentStateError, @@ -109,6 +110,7 @@ import { OrderModifier } from '../helpers/order-modifier/order-modifier'; import { OrderState } from '../helpers/order-state-machine/order-state'; import { OrderStateMachine } from '../helpers/order-state-machine/order-state-machine'; 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 { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calculator'; import { TranslatorService } from '../helpers/translator/translator.service'; @@ -988,6 +990,38 @@ export class OrderService { return result.fulfillment; } + /** + * @description + * Transitions a Refund to the given state + */ + async transitionRefundToState( + ctx: RequestContext, + refundId: ID, + state: RefundState, + transactionId?: string, + ): Promise { + 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 * Allows the Order to be modified, which allows several aspects of the Order to be changed: