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
@@ -38,6 +38,8 @@ interface DismissalState {
dismissed: boolean; dismissed: boolean;
} }
const FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT = 50;
export function shouldShowFirstRechargeOfferBanner( export function shouldShowFirstRechargeOfferBanner(
input: FirstRechargeOfferBannerEligibility, input: FirstRechargeOfferBannerEligibility,
): boolean { ): boolean {
@@ -66,7 +68,7 @@ export function useFirstRechargeOfferBanner({
loaded: false, loaded: false,
dismissed: false, dismissed: false,
})); }));
const discountPercent = payment.firstRechargeOffer?.discountPercent ?? 50; const discountPercent = FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT;
useEffect(() => { useEffect(() => {
if (!isAuthenticatedUser) return; if (!isAuthenticatedUser) return;
@@ -174,7 +174,6 @@ describe("subscription screen helpers", () => {
expect( expect(
getFirstRechargeOfferView({ getFirstRechargeOfferView({
isFirstRecharge: true, isFirstRecharge: true,
discountPercent: 50,
vipPlans: vipViews, vipPlans: vipViews,
coinPlans: coinViews, coinPlans: coinViews,
}), }),
@@ -182,6 +181,13 @@ describe("subscription screen helpers", () => {
badgeText: "50% OFF", badgeText: "50% OFF",
title: "First Recharge Offer", title: "First Recharge Offer",
}); });
expect(
getFirstRechargeOfferView({
isFirstRecharge: false,
vipPlans: vipViews,
coinPlans: coinViews,
}),
).toBeNull();
}); });
it("selects the first VIP plan by default when VIP can be purchased", () => { it("selects the first VIP plan by default when VIP can be purchased", () => {
@@ -157,17 +157,17 @@ export function getDefaultSubscriptionPlanId(input: {
export function getFirstRechargeOfferView(input: { export function getFirstRechargeOfferView(input: {
isFirstRecharge: boolean; isFirstRecharge: boolean;
discountPercent?: number | null;
vipPlans: readonly VipOfferPlanView[]; vipPlans: readonly VipOfferPlanView[];
coinPlans: readonly CoinsOfferPlanView[]; coinPlans: readonly CoinsOfferPlanView[];
}): FirstRechargeOfferView | null { }): FirstRechargeOfferView | null {
if (!input.isFirstRecharge) return null;
const promotedPlan = [...input.vipPlans, ...input.coinPlans].find( const promotedPlan = [...input.vipPlans, ...input.coinPlans].find(
(plan) => plan.isFirstRechargeOffer, (plan) => plan.isFirstRechargeOffer,
); );
if (!input.isFirstRecharge && !promotedPlan) return null;
const discountPercent = const discountPercent =
promotedPlan?.firstRechargeDiscountPercent ?? input.discountPercent ?? 50; promotedPlan?.firstRechargeDiscountPercent ?? 50;
const badgeText = `${discountPercent}% OFF`; const badgeText = `${discountPercent}% OFF`;
return { return {
badgeText, badgeText,
+1 -7
View File
@@ -77,16 +77,10 @@ export function SubscriptionScreen({
() => () =>
getFirstRechargeOfferView({ getFirstRechargeOfferView({
isFirstRecharge: payment.isFirstRecharge, isFirstRecharge: payment.isFirstRecharge,
discountPercent: payment.firstRechargeOffer?.discountPercent,
vipPlans: vipOfferPlans, vipPlans: vipOfferPlans,
coinPlans: directCoinsPlans, coinPlans: directCoinsPlans,
}), }),
[ [directCoinsPlans, payment.isFirstRecharge, vipOfferPlans],
directCoinsPlans,
payment.firstRechargeOffer?.discountPercent,
payment.isFirstRecharge,
vipOfferPlans,
],
); );
const selectedPlan = findSelectedSubscriptionPlan({ const selectedPlan = findSelectedSubscriptionPlan({
@@ -20,6 +20,9 @@ export interface IPaymentRepository {
/** 获取本地缓存套餐列表。 */ /** 获取本地缓存套餐列表。 */
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>; getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
/** 清除本地缓存套餐列表。 */
clearCachedPlans(): Promise<Result<void>>;
/** 获取会员套餐列表。 */ /** 获取会员套餐列表。 */
getVipPlans(): Promise<Result<PaymentPlan[]>>; getVipPlans(): Promise<Result<PaymentPlan[]>>;
@@ -41,6 +41,14 @@ export class PaymentRepository implements IPaymentRepository {
}); });
} }
/** 清除本地缓存套餐列表。 */
async clearCachedPlans(): Promise<Result<void>> {
return Result.wrap(async () => {
const result = await PaymentPlansStorage.clearPlans();
if (Result.isErr(result)) throw result.error;
});
}
/** 获取会员套餐列表。 */ /** 获取会员套餐列表。 */
async getVipPlans(): Promise<Result<PaymentPlan[]>> { async getVipPlans(): Promise<Result<PaymentPlan[]>> {
return Result.wrap(async () => { return Result.wrap(async () => {
@@ -145,7 +145,7 @@ describe("paymentMachine", () => {
actor.stop(); 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( const actor = createActor(
paymentMachine.provide({ paymentMachine.provide({
actors: { actors: {
@@ -181,7 +181,6 @@ describe("paymentMachine", () => {
const context = actor.getSnapshot().context; const context = actor.getSnapshot().context;
expect(context.isFirstRecharge).toBe(true); expect(context.isFirstRecharge).toBe(true);
expect(context.firstRechargeOffer?.discountPercent).toBe(50);
expect(context.plans[0]).toMatchObject({ expect(context.plans[0]).toMatchObject({
amountCents: 995, amountCents: 995,
originalAmountCents: 1990, originalAmountCents: 1990,
@@ -191,6 +190,55 @@ describe("paymentMachine", () => {
actor.stop(); 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 () => { it("hydrates cached plans before refreshing with network plans", async () => {
let resolveRefresh!: (value: PaymentPlansResponse) => void; let resolveRefresh!: (value: PaymentPlansResponse) => void;
const refreshPlansPromise = new Promise<PaymentPlansResponse>((resolve) => { const refreshPlansPromise = new Promise<PaymentPlansResponse>((resolve) => {
-2
View File
@@ -22,7 +22,6 @@ export interface PaymentContextState {
status: string; status: string;
plans: MachineContext["plans"]; plans: MachineContext["plans"];
isFirstRecharge: MachineContext["isFirstRecharge"]; isFirstRecharge: MachineContext["isFirstRecharge"];
firstRechargeOffer: MachineContext["firstRechargeOffer"];
selectedPlanId: string; selectedPlanId: string;
payChannel: MachineContext["payChannel"]; payChannel: MachineContext["payChannel"];
autoRenew: boolean; autoRenew: boolean;
@@ -53,7 +52,6 @@ export function PaymentProvider({ children }: PaymentProviderProps) {
status: String(state.value), status: String(state.value),
plans: state.context.plans, plans: state.context.plans,
isFirstRecharge: state.context.isFirstRecharge, isFirstRecharge: state.context.isFirstRecharge,
firstRechargeOffer: state.context.firstRechargeOffer,
selectedPlanId: state.context.selectedPlanId, selectedPlanId: state.context.selectedPlanId,
payChannel: state.context.payChannel, payChannel: state.context.payChannel,
autoRenew: state.context.autoRenew, autoRenew: state.context.autoRenew,
+1
View File
@@ -12,5 +12,6 @@ export type PaymentEvent =
| { type: "PaymentCreateOrderSubmitted" } | { type: "PaymentCreateOrderSubmitted" }
| { type: "PaymentReturned"; orderId: string; createdAt?: number } | { type: "PaymentReturned"; orderId: string; createdAt?: number }
| { type: "PaymentLaunchFailed"; errorMessage: string } | { type: "PaymentLaunchFailed"; errorMessage: string }
| { type: "PaymentFirstRechargeConsumed" }
| { type: "PaymentErrorCleared" } | { type: "PaymentErrorCleared" }
| { type: "PaymentReset" }; | { 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"; import type { PaymentState } from "./payment-state";
@@ -64,15 +64,17 @@ export function hydratePlansResponseState(
PaymentState, PaymentState,
| "plans" | "plans"
| "isFirstRecharge" | "isFirstRecharge"
| "firstRechargeOffer"
| "selectedPlanId" | "selectedPlanId"
| "autoRenew" | "autoRenew"
| "errorMessage" | "errorMessage"
> { > {
const plans = normalizeFirstRechargePlans(
response.plans,
response.isFirstRecharge,
);
return { return {
...hydratePlansState(response.plans, selectedPlanId), ...hydratePlansState(plans, selectedPlanId),
isFirstRecharge: response.isFirstRecharge, isFirstRecharge: response.isFirstRecharge,
firstRechargeOffer: response.firstRechargeOffer,
}; };
} }
@@ -101,15 +103,54 @@ export function refreshPlansResponseState(
PaymentState, PaymentState,
| "plans" | "plans"
| "isFirstRecharge" | "isFirstRecharge"
| "firstRechargeOffer"
| "selectedPlanId" | "selectedPlanId"
| "autoRenew" | "autoRenew"
| "errorMessage" | "errorMessage"
> { > {
const plans = normalizeFirstRechargePlans(
response.plans,
response.isFirstRecharge,
);
return { return {
...refreshPlansState(response.plans, selectedPlanId), ...refreshPlansState(plans, selectedPlanId),
isFirstRecharge: response.isFirstRecharge, 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"; } from "./payment-machine.actors";
import { import {
hasOrderPollingTimedOut, hasOrderPollingTimedOut,
consumeFirstRechargeState,
hydratePlansResponseState, hydratePlansResponseState,
PAYMENT_TIMEOUT_ERROR_MESSAGE, PAYMENT_TIMEOUT_ERROR_MESSAGE,
refreshPlansResponseState, refreshPlansResponseState,
@@ -326,6 +327,9 @@ export const paymentMachine = setup({
}, },
}, },
on: { on: {
PaymentFirstRechargeConsumed: {
actions: assign(({ context }) => consumeFirstRechargeState(context)),
},
PaymentReturned: { PaymentReturned: {
target: ".pollingOrder", target: ".pollingOrder",
actions: assign(({ event }) => ({ actions: assign(({ event }) => ({
-3
View File
@@ -2,7 +2,6 @@
* Payment 状态机:State 形状 + 初始值 * Payment 状态机:State 形状 + 初始值
*/ */
import type { import type {
FirstRechargeOffer,
PayChannel, PayChannel,
PaymentOrderStatus, PaymentOrderStatus,
PaymentPlan, PaymentPlan,
@@ -11,7 +10,6 @@ import type {
export interface PaymentState { export interface PaymentState {
plans: PaymentPlan[]; plans: PaymentPlan[];
isFirstRecharge: boolean; isFirstRecharge: boolean;
firstRechargeOffer: FirstRechargeOffer | null;
selectedPlanId: string; selectedPlanId: string;
payChannel: PayChannel; payChannel: PayChannel;
autoRenew: boolean; autoRenew: boolean;
@@ -27,7 +25,6 @@ export interface PaymentState {
export const initialState: PaymentState = { export const initialState: PaymentState = {
plans: [], plans: [],
isFirstRecharge: false, isFirstRecharge: false,
firstRechargeOffer: null,
selectedPlanId: "", selectedPlanId: "",
payChannel: "stripe", payChannel: "stripe",
autoRenew: true, autoRenew: true,
+9 -1
View File
@@ -12,10 +12,15 @@ import { useEffect, useRef } from "react";
import { useChatDispatch } from "@/stores/chat/chat-context"; import { useChatDispatch } from "@/stores/chat/chat-context";
import { useUserDispatch } from "@/stores/user/user-context"; import { useUserDispatch } from "@/stores/user/user-context";
import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session"; 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() { export function PaymentSuccessSync() {
const paymentState = usePaymentState(); const paymentState = usePaymentState();
const paymentDispatch = usePaymentDispatch();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const lastPaidKeyRef = useRef<string | null>(null); const lastPaidKeyRef = useRef<string | null>(null);
@@ -26,10 +31,12 @@ export function PaymentSuccessSync() {
const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`; const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`;
if (lastPaidKeyRef.current === paidKey) return; if (lastPaidKeyRef.current === paidKey) return;
lastPaidKeyRef.current = paidKey; lastPaidKeyRef.current = paidKey;
paymentDispatch({ type: "PaymentFirstRechargeConsumed" });
let cancelled = false; let cancelled = false;
const syncPaymentSuccess = async () => { const syncPaymentSuccess = async () => {
await getPaymentRepository().clearCachedPlans();
userDispatch({ type: "UserFetch" }); userDispatch({ type: "UserFetch" });
if (await hasPendingChatUnlock()) return; if (await hasPendingChatUnlock()) return;
if (cancelled) return; if (cancelled) return;
@@ -45,6 +52,7 @@ export function PaymentSuccessSync() {
paymentState.currentOrderId, paymentState.currentOrderId,
paymentState.isPaid, paymentState.isPaid,
paymentState.orderStatus, paymentState.orderStatus,
paymentDispatch,
userDispatch, userDispatch,
]); ]);