feat(payment): connect payment service flow

This commit is contained in:
2026-06-18 15:40:59 +08:00
parent 35c30ac31e
commit a347b39001
34 changed files with 759 additions and 889 deletions
+9
View File
@@ -0,0 +1,9 @@
/**
* @file Payment store barrel.
*/
export * from "./payment-context";
export * from "./payment-events";
export * from "./payment-machine";
export * from "./payment-machine.actors";
export * from "./payment-state";
+96
View File
@@ -0,0 +1,96 @@
"use client";
/**
* PaymentContext:基于 XState v5 的 React Context Provider
*/
import {
type Dispatch,
type ReactNode,
createContext,
useContext,
useMemo,
} from "react";
import { useMachine } from "@xstate/react";
import { paymentMachine } from "./payment-machine";
import type {
PaymentEvent,
PaymentState as MachineContext,
} from "./payment-machine";
export interface PaymentContextState {
status: string;
plans: MachineContext["plans"];
selectedPlanId: string;
payChannel: MachineContext["payChannel"];
autoRenew: boolean;
agreed: boolean;
currentOrderId: string | null;
payParams: Record<string, unknown> | null;
orderStatus: MachineContext["orderStatus"];
vipStatus: MachineContext["vipStatus"];
errorMessage: string | null;
launchNonce: number;
isLoadingPlans: boolean;
isCreatingOrder: boolean;
isPollingOrder: boolean;
isCheckingVipStatus: boolean;
isPaid: boolean;
}
const PaymentStateCtx = createContext<PaymentContextState | null>(null);
const PaymentDispatchCtx = createContext<Dispatch<PaymentEvent> | null>(null);
export interface PaymentProviderProps {
children: ReactNode;
}
export function PaymentProvider({ children }: PaymentProviderProps) {
const [state, send] = useMachine(paymentMachine);
const paymentState = useMemo<PaymentContextState>(
() => ({
status: String(state.value),
plans: state.context.plans,
selectedPlanId: state.context.selectedPlanId,
payChannel: state.context.payChannel,
autoRenew: state.context.autoRenew,
agreed: state.context.agreed,
currentOrderId: state.context.currentOrderId,
payParams: state.context.payParams,
orderStatus: state.context.orderStatus,
vipStatus: state.context.vipStatus,
errorMessage: state.context.errorMessage,
launchNonce: state.context.launchNonce,
isLoadingPlans: state.matches("loadingPlans"),
isCreatingOrder: state.matches("creatingOrder"),
isPollingOrder:
state.matches("pollingOrder") || state.matches("waitingForPayment"),
isCheckingVipStatus: state.matches("checkingVipStatus"),
isPaid: state.matches("paid"),
}),
[state],
);
return (
<PaymentStateCtx.Provider value={paymentState}>
<PaymentDispatchCtx.Provider value={send}>
{children}
</PaymentDispatchCtx.Provider>
</PaymentStateCtx.Provider>
);
}
export function usePaymentState(): PaymentContextState {
const ctx = useContext(PaymentStateCtx);
if (!ctx)
throw new Error("usePaymentState must be used inside <PaymentProvider>");
return ctx;
}
export function usePaymentDispatch(): Dispatch<PaymentEvent> {
const ctx = useContext(PaymentDispatchCtx);
if (!ctx)
throw new Error("usePaymentDispatch must be used inside <PaymentProvider>");
return ctx;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Payment 状态机:事件联合
*/
import type { PayChannel } from "@/data/dto/payment";
export type PaymentEvent =
| { type: "PaymentInit" }
| { type: "PaymentPlanSelected"; planId: string }
| { type: "PaymentPayChannelChanged"; payChannel: PayChannel }
| { type: "PaymentAutoRenewChanged"; autoRenew: boolean }
| { type: "PaymentAgreementChanged"; agreed: boolean }
| { type: "PaymentCreateOrderSubmitted" }
| { type: "PaymentRefreshVipStatus" }
| { type: "PaymentLaunchFailed"; errorMessage: string }
| { type: "PaymentErrorCleared" }
| { type: "PaymentReset" };
@@ -0,0 +1,54 @@
/**
* Payment 状态机:Actors(异步服务)
*/
import { fromPromise } from "xstate";
import {
CreatePaymentOrderResponse,
PayChannel,
PaymentOrderStatusResponse,
PaymentPlansResponse,
PaymentVipStatusResponse,
} from "@/data/dto/payment";
import { paymentRepository } from "@/data/repositories/payment_repository";
import type { IPaymentRepository } from "@/data/repositories/interfaces";
import { Result } from "@/utils";
const paymentRepo: IPaymentRepository = paymentRepository;
export const loadPaymentPlansActor = fromPromise<PaymentPlansResponse>(
async () => {
const result = await paymentRepo.getPlans();
if (Result.isErr(result)) throw result.error;
return result.data;
},
);
export const createPaymentOrderActor = fromPromise<
CreatePaymentOrderResponse,
{ planId: string; payChannel: PayChannel; autoRenew: boolean }
>(async ({ input }) => {
const result = await paymentRepo.createOrder(
input.planId,
input.payChannel,
input.autoRenew,
);
if (Result.isErr(result)) throw result.error;
return result.data;
});
export const pollPaymentOrderStatusActor = fromPromise<
PaymentOrderStatusResponse,
{ orderId: string }
>(async ({ input }) => {
const result = await paymentRepo.getOrderStatus(input.orderId);
if (Result.isErr(result)) throw result.error;
return result.data;
});
export const loadPaymentVipStatusActor =
fromPromise<PaymentVipStatusResponse>(async () => {
const result = await paymentRepo.getVipStatus();
if (Result.isErr(result)) throw result.error;
return result.data;
});
+305
View File
@@ -0,0 +1,305 @@
/**
* Payment 状态机(XState v5
*/
import { assign, setup } from "xstate";
import type { PaymentPlan } from "@/data/dto/payment";
import type { PaymentEvent } from "./payment-events";
import { initialState, type PaymentState } from "./payment-state";
import {
createPaymentOrderActor,
loadPaymentPlansActor,
loadPaymentVipStatusActor,
pollPaymentOrderStatusActor,
} from "./payment-machine.actors";
export type { PaymentEvent } from "./payment-events";
export type { PaymentState } from "./payment-state";
export { initialState } from "./payment-state";
const POLL_DELAY_MS = 4000;
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function defaultAutoRenewForPlan(planId: string): boolean {
return !planId.includes("lifetime") && !planId.startsWith("dol_");
}
function getDefaultPlanId(plans: readonly PaymentPlan[]): string {
return (
plans.find((plan) => plan.planId === "vip_monthly")?.planId ??
plans.find((plan) => plan.orderType.startsWith("vip_"))?.planId ??
plans[0]?.planId ??
""
);
}
function resetOrderState() {
return {
currentOrderId: null,
payParams: null,
orderStatus: null,
errorMessage: null,
} satisfies Partial<PaymentState>;
}
export const paymentMachine = setup({
types: {
context: {} as PaymentState,
events: {} as PaymentEvent,
},
actors: {
loadPlans: loadPaymentPlansActor,
createOrder: createPaymentOrderActor,
pollOrderStatus: pollPaymentOrderStatusActor,
loadVipStatus: loadPaymentVipStatusActor,
},
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QAcCGBPAtmAdgFwGIAFDbfAGVQFccBjACwDFUBLAG0gG0AGAXURQB7WCzwtBOASAAeiACwAmADQh0iAIwA2bnIB0CuQE5j67t20AOBQF9rKtFlx5dLCB2KknASRyie-JBBkYVFxSUDZBAVDPUMFdQs5dXUAVk0Adk0AZhSUlTUEdUMLXXV0w00LCyzzc00U23tPfBc3MA9HfAAlMAAzACc4egA1FmQAZTxUPCpYfylgkTEJKUjo2PjE5LTMnLzVRBTq3R1iuRTdzWz0xqDm5zZBVAgWHCgiNlQcWAIICTAXDgAG6CADWAIcZAeTxebw+X1gCFeINo0zC-nmgUWoRWEQ0WQsKV0lXSSQuFk0MUM6XyiAs6l0RmMxXSWS0FIUNjsd060Oer3en2+BDA-X6gn6umQnzwvQlmCl910j35cKFiORglRyxwGL4CxCOtW+MJxIspNS6QpVJpB0KpMZKTZBiSWXiMQst0hTl0g2e6A6UJ8fn1WMNYWNhRShiyujOCiq5yOSXUtKihm4jOilTkbKO6Ru3O9LT9EADJF58Jw4zAHFoeC4oaESwjeMKhlMunS3BqCiycm4VviWTTpnSCn02iycSdigMmi9StL5fuFYAwvQvjhaxuvjAIJjmzjwqBIgkrPoe9wFNxUhk5HI09TNLoauOMtxzRYO4ZF7zfWA-qBk4ACCVB4IIPTbgA7rubyNgER5Gm256xpocjVDkCgXNoCiaGmVSZjkSRJOhqTRlyTT-suwH4CBUCDGAUJwfuh5BOGuKnhoVREnhuTxCkWg4WmVp6AOphaEUKScvUf5QgBQEVsxfoNgA8v0ECiuMVAAEaYKIDYHk27EtpxMiIJyRRxuY6iKPScQ1I+drJOOXbqFh6RHLeTpyT6NFKU4a4qWA6maf02l6QZXDqIhJnHpGCQJG5FLnBUeHlPhdqVCUD5WhmpJVDGnpFkugFlrReA9AMQyjBMUwzHMxnYshXGFEYmZ8YJGbnOkWjKHa-aZnEFRWPEZR9b5JZlSuvIAKJihKa4cKggxGbFzWtq1RTqBOBjFISBYKFamRphRjIxJkTrXmkySTc4tAqQKoWir8-yAiC4KKv+D2AWIbzPf0SLAlqaISHq60cSe5lRCRr47VkGQUgS2SjtGeiksyn6JvSDQld9j3-RpL2iuKkrStMcr9Aqxb3QTUAA0DKKg7qfBsRtZlrGSxJpNwHb0vehijm+XYjfUD7xBhxVUfJwRsGwT1E-0r3bu9YIQkqsvy4TYWMyDOrgwaplQ2sMRxpsKY7NkuRptksYpEYiQI9GKTXoW0s+prCthcrAKap9NNSoIcte6KuvauirMxYb8VtusZsJBbGRW-sBTuTojJyJU1xuuh0Z3YHwfay9fwq376v-p7ReA5q4dg6zCgQ0bkZx3ECfbEnexPjUjKsjGmimAOWT9vnlf04rIoLWTMqU9TGtB1rY86zXzMG2GTex6brdbHeuzW85Y3nVkrIWNwgnJHh+fQawf1QIwEoBfgFU9LAYB4GzkORhSmZWjtO3GAO9QLBPj7K+DM-dM4fmqFLHk8kr6hDeHffoD9CDSFgPVAEqBegNn6AACgHGYAAlAQAOcCb6IOQe-derUjB6G-KfeoToMhD3OARC81JpxH2pNEGMbsYE+gYGAWgoIBS1UmNMWYPtVb+yVAIoRIixhiIamHFerMmofzbF-Ls9J4jcIAUcESHYTjTnoaYMwpJeEB1kcIt4oj6oSJLr7YG0jvr0EEdYqAtjxEamBrXFmvBOBRzXjHVqmif46P-uYfRdpM4MgyCNbYFQcgWJka4uRNiFF2J+CTCUUpp7yi+vJKx8i6peOUfrVRjdgnQ1Cdov+MRIlAKysUE4hIYw6ASPbWy+cinpJKQ1CepNckU3yZY1J7jPFKOXuU-xgSkKbWqdoLRv9dENNOvbYkboT45mSJSEerAIBPzgK-ShVSzwEiJCSMkVpKRGFtAUAshg4y2VPkUXMHZPJ7NcIc6qsARgZK8Sclq0MOwvk6kcXIMRNg2zkOkXQ4KYzYU5N2Ps+deisA4Ac5BQVfohUVhFfSeBDKAvmWc00lzLTWluaOB8jyCz9gTAyjsaRUXosgBVeapMlqAVWsSjmJoLnmiuZS6ko46i6ApL1OlGZsLXhZewNlyDn7HLUVQ6G7kyWCopTckVzk+ripvL2aogDoi2G5DgQQml4BYnuNHIFkQAC0mUCj2qJMyN17qMyn3zq4DgtqSXcVdZsPMmdFAJBHHaC4sL3JskMNGdp05fx43kiqWEgoER+r5YUMotKEiVHtrsQko5PKlAyjGfsmF7z52XBm42GhqQMm7L2TkVhpyslHGUWMw5BLlBPrkVk3S6YAxrc3fusKeE5DiZ61Mzk0hEhzmkct15eaUT4S0UeQ6gl2vkP1VON49BulJGYG6R7L7XwFOQm1m7-UIE0HhOG84Bx7quE6xAMQXyjp0e+bQPZuljOKYo2Yw62z9xKPUW95hEnfkaQUfu762naLiKyDInyIBAa2jeF8v9rzfmMAJHddIeylAATZKSJI5UYrQ2qvsRIew7JqIlKoQs70dqSCfbC34CymusEAA */
id: "payment",
initial: "idle",
context: initialState,
states: {
idle: {
on: {
PaymentInit: "loadingPlans",
PaymentRefreshVipStatus: "checkingVipStatus",
},
},
loadingPlans: {
invoke: {
src: "loadPlans",
onDone: {
target: "ready",
actions: assign(({ context, event }) => {
const plans = event.output.plans;
const selectedPlanId =
context.selectedPlanId || getDefaultPlanId(plans);
return {
plans,
selectedPlanId,
autoRenew: defaultAutoRenewForPlan(selectedPlanId),
errorMessage: null,
};
}),
},
onError: {
target: "ready",
actions: assign(({ event }) => ({
errorMessage: toErrorMessage(event.error),
})),
},
},
},
ready: {
on: {
PaymentInit: "loadingPlans",
PaymentPlanSelected: {
actions: assign(({ event }) => ({
...resetOrderState(),
selectedPlanId: event.planId,
autoRenew: defaultAutoRenewForPlan(event.planId),
})),
},
PaymentPayChannelChanged: {
actions: assign(({ event }) => ({
payChannel: event.payChannel,
errorMessage: null,
})),
},
PaymentAutoRenewChanged: {
actions: assign(({ event }) => ({
autoRenew: event.autoRenew,
errorMessage: null,
})),
},
PaymentAgreementChanged: {
actions: assign(({ event }) => ({
agreed: event.agreed,
errorMessage: null,
})),
},
PaymentCreateOrderSubmitted: [
{
guard: ({ context }) =>
context.agreed && context.selectedPlanId.length > 0,
target: "creatingOrder",
},
{
actions: assign({
errorMessage:
"Choose a plan and accept the agreement before continuing.",
}),
},
],
PaymentRefreshVipStatus: "checkingVipStatus",
PaymentErrorCleared: {
actions: assign({ errorMessage: null }),
},
},
},
creatingOrder: {
invoke: {
src: "createOrder",
input: ({ context }) => ({
planId: context.selectedPlanId,
payChannel: context.payChannel,
autoRenew: context.autoRenew,
}),
onDone: {
target: "pollingOrder",
actions: assign(({ context, event }) => ({
currentOrderId: event.output.orderId,
payParams: event.output.payParams,
orderStatus: "pending",
errorMessage: null,
launchNonce: context.launchNonce + 1,
})),
},
onError: {
target: "failed",
actions: assign(({ event }) => ({
errorMessage: toErrorMessage(event.error),
})),
},
},
},
pollingOrder: {
invoke: {
src: "pollOrderStatus",
input: ({ context }) => ({
orderId: context.currentOrderId ?? "",
}),
onDone: [
{
guard: ({ event }) => event.output.status === "paid",
target: "checkingVipStatus",
actions: assign(({ event }) => ({
orderStatus: event.output.status,
errorMessage: null,
})),
},
{
guard: ({ event }) => event.output.status === "failed",
target: "failed",
actions: assign(({ event }) => ({
orderStatus: event.output.status,
errorMessage: "Payment failed or was cancelled.",
})),
},
{
target: "waitingForPayment",
actions: assign(({ event }) => ({
orderStatus: event.output.status,
errorMessage: null,
})),
},
],
onError: {
target: "failed",
actions: assign(({ event }) => ({
errorMessage: toErrorMessage(event.error),
})),
},
},
},
waitingForPayment: {
after: {
[POLL_DELAY_MS]: "pollingOrder",
},
on: {
PaymentReset: {
target: "ready",
actions: assign(resetOrderState),
},
},
},
checkingVipStatus: {
invoke: {
src: "loadVipStatus",
onDone: [
{
guard: ({ context }) => context.orderStatus === "paid",
target: "paid",
actions: assign(({ event }) => ({
vipStatus: event.output,
errorMessage: null,
})),
},
{
target: "ready",
actions: assign(({ event }) => ({
vipStatus: event.output,
errorMessage: null,
})),
},
],
onError: [
{
guard: ({ context }) => context.orderStatus === "paid",
target: "paid",
actions: assign(({ event }) => ({
errorMessage: toErrorMessage(event.error),
})),
},
{
target: "ready",
actions: assign(({ event }) => ({
errorMessage: toErrorMessage(event.error),
})),
},
],
},
},
paid: {
on: {
PaymentReset: {
target: "ready",
actions: assign(resetOrderState),
},
PaymentRefreshVipStatus: "checkingVipStatus",
},
},
failed: {
on: {
PaymentCreateOrderSubmitted: [
{
guard: ({ context }) =>
context.agreed && context.selectedPlanId.length > 0,
target: "creatingOrder",
actions: assign(resetOrderState),
},
],
PaymentErrorCleared: {
target: "ready",
actions: assign({ errorMessage: null }),
},
PaymentReset: {
target: "ready",
actions: assign(resetOrderState),
},
},
},
},
on: {
PaymentLaunchFailed: {
target: ".failed",
actions: assign(({ event }) => ({
errorMessage: event.errorMessage,
})),
},
},
});
export type PaymentMachine = typeof paymentMachine;
+37
View File
@@ -0,0 +1,37 @@
/**
* Payment 状态机:State 形状 + 初始值
*/
import type {
PayChannel,
PaymentOrderStatus,
PaymentPlan,
PaymentVipStatusResponse,
} from "@/data/dto/payment";
export interface PaymentState {
plans: PaymentPlan[];
selectedPlanId: string;
payChannel: PayChannel;
autoRenew: boolean;
agreed: boolean;
currentOrderId: string | null;
payParams: Record<string, unknown> | null;
orderStatus: PaymentOrderStatus | null;
vipStatus: PaymentVipStatusResponse | null;
errorMessage: string | null;
launchNonce: number;
}
export const initialState: PaymentState = {
plans: [],
selectedPlanId: "",
payChannel: "stripe",
autoRenew: true,
agreed: false,
currentOrderId: null,
payParams: null,
orderStatus: null,
vipStatus: null,
errorMessage: null,
launchNonce: 0,
};
-2
View File
@@ -58,7 +58,6 @@ export function toView(u: {
isGuest?: boolean;
isVip?: boolean;
voiceMinutesRemaining?: number;
stripeCustomerId?: string | null;
}): UserView {
return {
id: u.id,
@@ -72,7 +71,6 @@ export function toView(u: {
isGuest: u.isGuest ?? false,
isVip: u.isVip ?? false,
voiceMinutesRemaining: u.voiceMinutesRemaining ?? 0,
stripeCustomerId: u.stripeCustomerId ?? null,
};
}