feat(payment): implement first recharge offer functionality and UI updates

This commit is contained in:
2026-07-02 14:39:09 +08:00
parent 7b35fd18c5
commit d4de1370e8
20 changed files with 607 additions and 50 deletions
@@ -145,6 +145,52 @@ describe("paymentMachine", () => {
actor.stop();
});
it("keeps first recharge offer metadata from the plans response", async () => {
const actor = createActor(
paymentMachine.provide({
actors: {
loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
async () => null,
),
refreshPlans: fromPromise(async () =>
PaymentPlansResponse.from({
isFirstRecharge: true,
firstRechargeOffer: {
enabled: true,
type: "first_recharge_half_price",
discountPercent: 50,
},
plans: [
{
...monthlyPlan,
amountCents: 995,
originalAmountCents: 1990,
isFirstRechargeOffer: true,
firstRechargeDiscountPercent: 50,
promotionType: "first_recharge_half_price",
},
],
}),
),
},
}),
).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context;
expect(context.isFirstRecharge).toBe(true);
expect(context.firstRechargeOffer?.discountPercent).toBe(50);
expect(context.plans[0]).toMatchObject({
amountCents: 995,
originalAmountCents: 1990,
isFirstRechargeOffer: true,
});
actor.stop();
});
it("hydrates cached plans before refreshing with network plans", async () => {
let resolveRefresh!: (value: PaymentPlansResponse) => void;
const refreshPlansPromise = new Promise<PaymentPlansResponse>((resolve) => {
+4
View File
@@ -21,6 +21,8 @@ import type {
export interface PaymentContextState {
status: string;
plans: MachineContext["plans"];
isFirstRecharge: MachineContext["isFirstRecharge"];
firstRechargeOffer: MachineContext["firstRechargeOffer"];
selectedPlanId: string;
payChannel: MachineContext["payChannel"];
autoRenew: boolean;
@@ -50,6 +52,8 @@ export function PaymentProvider({ children }: PaymentProviderProps) {
() => ({
status: String(state.value),
plans: state.context.plans,
isFirstRecharge: state.context.isFirstRecharge,
firstRechargeOffer: state.context.firstRechargeOffer,
selectedPlanId: state.context.selectedPlanId,
payChannel: state.context.payChannel,
autoRenew: state.context.autoRenew,
+47 -3
View File
@@ -1,4 +1,4 @@
import type { PaymentPlan } from "@/data/dto/payment";
import type { PaymentPlan, PaymentPlansResponse } from "@/data/dto/payment";
import type { PaymentState } from "./payment-state";
@@ -44,7 +44,10 @@ export function getDefaultPlanId(plans: readonly PaymentPlan[]): string {
export function hydratePlansState(
plans: PaymentPlan[],
selectedPlanId: string,
): Pick<PaymentState, "plans" | "selectedPlanId" | "autoRenew" | "errorMessage"> {
): Pick<
PaymentState,
"plans" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const nextSelectedPlanId = selectedPlanId || getDefaultPlanId(plans);
return {
plans,
@@ -54,10 +57,32 @@ export function hydratePlansState(
};
}
export function hydratePlansResponseState(
response: PaymentPlansResponse,
selectedPlanId: string,
): Pick<
PaymentState,
| "plans"
| "isFirstRecharge"
| "firstRechargeOffer"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
> {
return {
...hydratePlansState(response.plans, selectedPlanId),
isFirstRecharge: response.isFirstRecharge,
firstRechargeOffer: response.firstRechargeOffer,
};
}
export function refreshPlansState(
plans: PaymentPlan[],
selectedPlanId: string,
): Pick<PaymentState, "plans" | "selectedPlanId" | "autoRenew" | "errorMessage"> {
): Pick<
PaymentState,
"plans" | "selectedPlanId" | "autoRenew" | "errorMessage"
> {
const nextSelectedPlanId = plans.some((plan) => plan.planId === selectedPlanId)
? selectedPlanId
: getDefaultPlanId(plans);
@@ -69,6 +94,25 @@ export function refreshPlansState(
};
}
export function refreshPlansResponseState(
response: PaymentPlansResponse,
selectedPlanId: string,
): Pick<
PaymentState,
| "plans"
| "isFirstRecharge"
| "firstRechargeOffer"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
> {
return {
...refreshPlansState(response.plans, selectedPlanId),
isFirstRecharge: response.isFirstRecharge,
firstRechargeOffer: response.firstRechargeOffer,
};
}
export function selectPlanState(
planId: string,
plans: readonly PaymentPlan[],
+15 -8
View File
@@ -13,9 +13,9 @@ import {
} from "./payment-machine.actors";
import {
hasOrderPollingTimedOut,
hydratePlansState,
hydratePlansResponseState,
PAYMENT_TIMEOUT_ERROR_MESSAGE,
refreshPlansState,
refreshPlansResponseState,
resetOrderState,
selectPlanState,
toPaymentErrorMessage,
@@ -63,8 +63,11 @@ export const paymentMachine = setup({
guard: ({ event }) => (event.output?.plans.length ?? 0) > 0,
target: "refreshingPlans",
actions: assign(({ context, event }) => {
const plans = event.output?.plans ?? [];
return hydratePlansState(plans, context.selectedPlanId);
if (!event.output) return {};
return hydratePlansResponseState(
event.output,
context.selectedPlanId,
);
}),
},
{
@@ -83,8 +86,10 @@ export const paymentMachine = setup({
onDone: {
target: "ready",
actions: assign(({ context, event }) => {
const plans = event.output.plans;
return hydratePlansState(plans, context.selectedPlanId);
return hydratePlansResponseState(
event.output,
context.selectedPlanId,
);
}),
},
onError: {
@@ -102,8 +107,10 @@ export const paymentMachine = setup({
onDone: {
target: "ready",
actions: assign(({ context, event }) => {
const plans = event.output.plans;
return refreshPlansState(plans, context.selectedPlanId);
return refreshPlansResponseState(
event.output,
context.selectedPlanId,
);
}),
},
onError: {
+5
View File
@@ -2,6 +2,7 @@
* Payment 状态机:State 形状 + 初始值
*/
import type {
FirstRechargeOffer,
PayChannel,
PaymentOrderStatus,
PaymentPlan,
@@ -9,6 +10,8 @@ import type {
export interface PaymentState {
plans: PaymentPlan[];
isFirstRecharge: boolean;
firstRechargeOffer: FirstRechargeOffer | null;
selectedPlanId: string;
payChannel: PayChannel;
autoRenew: boolean;
@@ -23,6 +26,8 @@ export interface PaymentState {
export const initialState: PaymentState = {
plans: [],
isFirstRecharge: false,
firstRechargeOffer: null,
selectedPlanId: "",
payChannel: "stripe",
autoRenew: true,