From 5cee5f040ed5621ee4443aa694a4d065ab211df1 Mon Sep 17 00:00:00 2001 From: Tiis Date: Wed, 10 Jul 2024 21:20:55 +0900 Subject: [PATCH 1/2] feat(core): Modified refundProcess to be extensible --- packages/core/src/config/config.module.ts | 7 +- packages/core/src/config/config.service.ts | 4 + packages/core/src/config/default-config.ts | 4 + 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 | 24 +++++ .../refund-state-machine.ts | 101 ++++++++++++------ .../refund-state-machine/refund-state.ts | 34 +++--- .../src/service/services/order.service.ts | 34 ++++++ 10 files changed, 255 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..39fd0eecea 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 { process: refundProcess } = this.configService.refundOptions; return [ ...adminAuthenticationStrategy, ...shopAuthenticationStrategy, @@ -145,6 +149,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo stockLocationStrategy, productVariantPriceSelectionStrategy, guestCheckoutStrategy, + ...refundProcess, ]; } diff --git a/packages/core/src/config/config.service.ts b/packages/core/src/config/config.service.ts index 50367a8239..9e04089eda 100644 --- a/packages/core/src/config/config.service.ts +++ b/packages/core/src/config/config.service.ts @@ -120,6 +120,10 @@ export class ConfigService implements VendureConfig { return this.activeConfig.systemOptions; } + get refundOptions() { + return this.activeConfig.refundOptions; + } + private getCustomFieldsForAllEntities(): Required { const definedCustomFields = this.activeConfig.customFields; const metadataArgsStorage = getMetadataArgsStorage(); diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index d55f5c1ec2..94514f10db 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'; @@ -220,4 +221,7 @@ export const defaultConfig: RuntimeVendureConfig = { healthChecks: [new TypeORMHealthCheckStrategy()], errorHandlers: [], }, + refundOptions: { + process: [defaultRefundProcess], + }, }; 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..4a02c896ed 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'; @@ -1052,6 +1053,23 @@ export interface SystemOptions { errorHandlers?: ErrorHandlerStrategy[]; } +/** + * @description + * Defines refund-related options in the {@link VendureConfig}. + * + * @docsCategory refund + * */ +export interface RefundOptions { + /** + * @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 + */ + process?: Array>; +} + /** * @description * All possible configuration options are defined by the @@ -1180,6 +1198,11 @@ export interface VendureConfig { * @since 1.6.0 */ systemOptions?: SystemOptions; + /** + * @description + * Configures available refund processing methods. + */ + refundOptions?: RefundOptions; } /** @@ -1203,6 +1226,7 @@ export interface RuntimeVendureConfig extends Required { shippingOptions: Required; taxOptions: Required; systemOptions: Required; + refundOptions: Required; } type DeepPartialSimple = { 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..c2c88d02b6 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.refundOptions.process ?? [])]; + 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: From cdceffef6e827154f25cdb57ec49eeeedfaef061 Mon Sep 17 00:00:00 2001 From: Tiis Date: Tue, 16 Jul 2024 20:12:40 +0900 Subject: [PATCH 2/2] fix(core): Move refundProcess under paymentOptions --- packages/core/src/config/config.module.ts | 2 +- packages/core/src/config/config.service.ts | 4 --- packages/core/src/config/default-config.ts | 4 +-- packages/core/src/config/vendure-config.ts | 31 +++++-------------- .../refund-state-machine.ts | 2 +- 5 files changed, 11 insertions(+), 32 deletions(-) diff --git a/packages/core/src/config/config.module.ts b/packages/core/src/config/config.module.ts index 39fd0eecea..8c7cb88feb 100644 --- a/packages/core/src/config/config.module.ts +++ b/packages/core/src/config/config.module.ts @@ -109,7 +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 { process: refundProcess } = this.configService.refundOptions; + const { refundProcess: refundProcess } = this.configService.paymentOptions; return [ ...adminAuthenticationStrategy, ...shopAuthenticationStrategy, diff --git a/packages/core/src/config/config.service.ts b/packages/core/src/config/config.service.ts index 9e04089eda..50367a8239 100644 --- a/packages/core/src/config/config.service.ts +++ b/packages/core/src/config/config.service.ts @@ -120,10 +120,6 @@ export class ConfigService implements VendureConfig { return this.activeConfig.systemOptions; } - get refundOptions() { - return this.activeConfig.refundOptions; - } - private getCustomFieldsForAllEntities(): Required { const definedCustomFields = this.activeConfig.customFields; const metadataArgsStorage = getMetadataArgsStorage(); diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index 94514f10db..e7b5f5b6dc 100644 --- a/packages/core/src/config/default-config.ts +++ b/packages/core/src/config/default-config.ts @@ -171,6 +171,7 @@ export const defaultConfig: RuntimeVendureConfig = { paymentMethodHandlers: [], customPaymentProcess: [], process: [defaultPaymentProcess], + refundProcess: [defaultRefundProcess], }, taxOptions: { taxZoneStrategy: new DefaultTaxZoneStrategy(), @@ -221,7 +222,4 @@ export const defaultConfig: RuntimeVendureConfig = { healthChecks: [new TypeORMHealthCheckStrategy()], errorHandlers: [], }, - refundOptions: { - process: [defaultRefundProcess], - }, }; diff --git a/packages/core/src/config/vendure-config.ts b/packages/core/src/config/vendure-config.ts index 4a02c896ed..3ce74aadb4 100644 --- a/packages/core/src/config/vendure-config.ts +++ b/packages/core/src/config/vendure-config.ts @@ -849,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>; } /** @@ -1053,23 +1061,6 @@ export interface SystemOptions { errorHandlers?: ErrorHandlerStrategy[]; } -/** - * @description - * Defines refund-related options in the {@link VendureConfig}. - * - * @docsCategory refund - * */ -export interface RefundOptions { - /** - * @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 - */ - process?: Array>; -} - /** * @description * All possible configuration options are defined by the @@ -1198,11 +1189,6 @@ export interface VendureConfig { * @since 1.6.0 */ systemOptions?: SystemOptions; - /** - * @description - * Configures available refund processing methods. - */ - refundOptions?: RefundOptions; } /** @@ -1226,7 +1212,6 @@ export interface RuntimeVendureConfig extends Required { shippingOptions: Required; taxOptions: Required; systemOptions: Required; - refundOptions: Required; } type DeepPartialSimple = { 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 c2c88d02b6..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 @@ -40,7 +40,7 @@ export class RefundStateMachine { } private initConfig(): StateMachineConfig { - const processes = [...(this.configService.refundOptions.process ?? [])]; + const processes = [...(this.configService.paymentOptions.refundProcess ?? [])]; const allTransitions = processes.reduce( (transitions, process) => mergeTransitionDefinitions(transitions, process.transitions as Transitions),