Skip to content

Commit

Permalink
feat(core): Make Refund process configurable (#2942)
Browse files Browse the repository at this point in the history
  • Loading branch information
Feelw00 authored Jul 16, 2024
1 parent b02caea commit c8f1d62
Show file tree
Hide file tree
Showing 9 changed files with 234 additions and 48 deletions.
7 changes: 6 additions & 1 deletion packages/core/src/config/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -145,6 +149,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
stockLocationStrategy,
productVariantPriceSelectionStrategy,
guestCheckoutStrategy,
...refundProcess,
];
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/config/default-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -170,6 +171,7 @@ export const defaultConfig: RuntimeVendureConfig = {
paymentMethodHandlers: [],
customPaymentProcess: [],
process: [defaultPaymentProcess],
refundProcess: [defaultRefundProcess],
},
taxOptions: {
taxZoneStrategy: new DefaultTaxZoneStrategy(),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
53 changes: 53 additions & 0 deletions packages/core/src/config/refund/default-refund-process.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 41 additions & 0 deletions packages/core/src/config/refund/refund-process.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 9 additions & 0 deletions packages/core/src/config/vendure-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -848,6 +849,14 @@ export interface PaymentOptions {
* @since 2.0.0
*/
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>>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<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[] {
const fsm = new FSM(this.config, refund.state);
Expand All @@ -53,4 +38,58 @@ export class RefundStateMachine {
refund.state = state;
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,
});
},
};
}
}
Original file line number Diff line number Diff line change
@@ -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<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
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/service/services/order.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
CancelPaymentError,
EmptyOrderLineSelectionError,
FulfillmentStateTransitionError,
RefundStateTransitionError,
InsufficientStockOnHandError,
ItemsAlreadyFulfilledError,
ManualPaymentStateError,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<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
* Allows the Order to be modified, which allows several aspects of the Order to be changed:
Expand Down

0 comments on commit c8f1d62

Please sign in to comment.