fix(payment): consume first recharge offer after payment

This commit is contained in:
2026-07-03 10:54:46 +08:00
parent bb6ebbae7e
commit 489554cd78
13 changed files with 137 additions and 27 deletions
@@ -145,7 +145,7 @@ describe("paymentMachine", () => {
actor.stop();
});
it("keeps first recharge offer metadata from the plans response", async () => {
it("keeps first recharge flag and plan metadata from the plans response", async () => {
const actor = createActor(
paymentMachine.provide({
actors: {
@@ -181,7 +181,6 @@ describe("paymentMachine", () => {
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,
@@ -191,6 +190,55 @@ describe("paymentMachine", () => {
actor.stop();
});
it("consumes first recharge offer state after payment succeeds", 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"));
actor.send({ type: "PaymentFirstRechargeConsumed" });
const context = actor.getSnapshot().context;
expect(context.isFirstRecharge).toBe(false);
expect(context.plans[0]).toMatchObject({
amountCents: 1990,
originalAmountCents: null,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: null,
promotionType: null,
});
actor.stop();
});
it("hydrates cached plans before refreshing with network plans", async () => {
let resolveRefresh!: (value: PaymentPlansResponse) => void;
const refreshPlansPromise = new Promise<PaymentPlansResponse>((resolve) => {
-2
View File
@@ -22,7 +22,6 @@ export interface PaymentContextState {
status: string;
plans: MachineContext["plans"];
isFirstRecharge: MachineContext["isFirstRecharge"];
firstRechargeOffer: MachineContext["firstRechargeOffer"];
selectedPlanId: string;
payChannel: MachineContext["payChannel"];
autoRenew: boolean;
@@ -53,7 +52,6 @@ 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,
+1
View File
@@ -12,5 +12,6 @@ export type PaymentEvent =
| { type: "PaymentCreateOrderSubmitted" }
| { type: "PaymentReturned"; orderId: string; createdAt?: number }
| { type: "PaymentLaunchFailed"; errorMessage: string }
| { type: "PaymentFirstRechargeConsumed" }
| { type: "PaymentErrorCleared" }
| { type: "PaymentReset" };
+48 -7
View File
@@ -1,4 +1,4 @@
import type { PaymentPlan, PaymentPlansResponse } from "@/data/dto/payment";
import { PaymentPlan, type PaymentPlansResponse } from "@/data/dto/payment";
import type { PaymentState } from "./payment-state";
@@ -64,15 +64,17 @@ export function hydratePlansResponseState(
PaymentState,
| "plans"
| "isFirstRecharge"
| "firstRechargeOffer"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
> {
const plans = normalizeFirstRechargePlans(
response.plans,
response.isFirstRecharge,
);
return {
...hydratePlansState(response.plans, selectedPlanId),
...hydratePlansState(plans, selectedPlanId),
isFirstRecharge: response.isFirstRecharge,
firstRechargeOffer: response.firstRechargeOffer,
};
}
@@ -101,15 +103,54 @@ export function refreshPlansResponseState(
PaymentState,
| "plans"
| "isFirstRecharge"
| "firstRechargeOffer"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
> {
const plans = normalizeFirstRechargePlans(
response.plans,
response.isFirstRecharge,
);
return {
...refreshPlansState(response.plans, selectedPlanId),
...refreshPlansState(plans, selectedPlanId),
isFirstRecharge: response.isFirstRecharge,
firstRechargeOffer: response.firstRechargeOffer,
};
}
export function normalizeFirstRechargePlans(
plans: readonly PaymentPlan[],
isFirstRecharge: boolean,
): PaymentPlan[] {
if (isFirstRecharge) return [...plans];
return plans.map(consumeFirstRechargePlan);
}
export function consumeFirstRechargePlan(plan: PaymentPlan): PaymentPlan {
const originalAmountCents = plan.originalAmountCents;
return PaymentPlan.from({
...plan.toJson(),
amountCents: originalAmountCents ?? plan.amountCents,
originalAmountCents: null,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: null,
promotionType: null,
});
}
export function consumeFirstRechargeState(
context: PaymentState,
): Pick<
PaymentState,
| "plans"
| "isFirstRecharge"
| "selectedPlanId"
| "autoRenew"
| "errorMessage"
> {
const plans = context.plans.map(consumeFirstRechargePlan);
return {
...refreshPlansState(plans, context.selectedPlanId),
isFirstRecharge: false,
};
}
+4
View File
@@ -13,6 +13,7 @@ import {
} from "./payment-machine.actors";
import {
hasOrderPollingTimedOut,
consumeFirstRechargeState,
hydratePlansResponseState,
PAYMENT_TIMEOUT_ERROR_MESSAGE,
refreshPlansResponseState,
@@ -326,6 +327,9 @@ export const paymentMachine = setup({
},
},
on: {
PaymentFirstRechargeConsumed: {
actions: assign(({ context }) => consumeFirstRechargeState(context)),
},
PaymentReturned: {
target: ".pollingOrder",
actions: assign(({ event }) => ({
-3
View File
@@ -2,7 +2,6 @@
* Payment 状态机:State 形状 + 初始值
*/
import type {
FirstRechargeOffer,
PayChannel,
PaymentOrderStatus,
PaymentPlan,
@@ -11,7 +10,6 @@ import type {
export interface PaymentState {
plans: PaymentPlan[];
isFirstRecharge: boolean;
firstRechargeOffer: FirstRechargeOffer | null;
selectedPlanId: string;
payChannel: PayChannel;
autoRenew: boolean;
@@ -27,7 +25,6 @@ export interface PaymentState {
export const initialState: PaymentState = {
plans: [],
isFirstRecharge: false,
firstRechargeOffer: null,
selectedPlanId: "",
payChannel: "stripe",
autoRenew: true,
+9 -1
View File
@@ -12,10 +12,15 @@ import { useEffect, useRef } from "react";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { useUserDispatch } from "@/stores/user/user-context";
import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session";
import { usePaymentState } from "@/stores/payment/payment-context";
import {
usePaymentDispatch,
usePaymentState,
} from "@/stores/payment/payment-context";
import { getPaymentRepository } from "@/data/repositories/payment_repository";
export function PaymentSuccessSync() {
const paymentState = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const userDispatch = useUserDispatch();
const chatDispatch = useChatDispatch();
const lastPaidKeyRef = useRef<string | null>(null);
@@ -26,10 +31,12 @@ export function PaymentSuccessSync() {
const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`;
if (lastPaidKeyRef.current === paidKey) return;
lastPaidKeyRef.current = paidKey;
paymentDispatch({ type: "PaymentFirstRechargeConsumed" });
let cancelled = false;
const syncPaymentSuccess = async () => {
await getPaymentRepository().clearCachedPlans();
userDispatch({ type: "UserFetch" });
if (await hasPendingChatUnlock()) return;
if (cancelled) return;
@@ -45,6 +52,7 @@ export function PaymentSuccessSync() {
paymentState.currentOrderId,
paymentState.isPaid,
paymentState.orderStatus,
paymentDispatch,
userDispatch,
]);