From 489554cd78c77b83cbe29e832a02ed626a236456 Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 3 Jul 2026 10:54:46 +0800 Subject: [PATCH] fix(payment): consume first recharge offer after payment --- .../hooks/use-first-recharge-offer-banner.ts | 4 +- .../subscription-screen.helpers.test.ts | 8 ++- .../subscription-screen.helpers.ts | 6 +- src/app/subscription/subscription-screen.tsx | 8 +-- .../interfaces/ipayment_repository.ts | 3 + src/data/repositories/payment_repository.ts | 8 +++ .../payment/__tests__/payment-machine.test.ts | 52 +++++++++++++++++- src/stores/payment/payment-context.tsx | 2 - src/stores/payment/payment-events.ts | 1 + src/stores/payment/payment-machine.helpers.ts | 55 ++++++++++++++++--- src/stores/payment/payment-machine.ts | 4 ++ src/stores/payment/payment-state.ts | 3 - src/stores/sync/payment-success-sync.tsx | 10 +++- 13 files changed, 137 insertions(+), 27 deletions(-) diff --git a/src/app/chat/hooks/use-first-recharge-offer-banner.ts b/src/app/chat/hooks/use-first-recharge-offer-banner.ts index 88fb5b62..a6948d08 100644 --- a/src/app/chat/hooks/use-first-recharge-offer-banner.ts +++ b/src/app/chat/hooks/use-first-recharge-offer-banner.ts @@ -38,6 +38,8 @@ interface DismissalState { dismissed: boolean; } +const FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT = 50; + export function shouldShowFirstRechargeOfferBanner( input: FirstRechargeOfferBannerEligibility, ): boolean { @@ -66,7 +68,7 @@ export function useFirstRechargeOfferBanner({ loaded: false, dismissed: false, })); - const discountPercent = payment.firstRechargeOffer?.discountPercent ?? 50; + const discountPercent = FIRST_RECHARGE_DEFAULT_DISCOUNT_PERCENT; useEffect(() => { if (!isAuthenticatedUser) return; diff --git a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts index 2317a5d8..8835c8f2 100644 --- a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts +++ b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts @@ -174,7 +174,6 @@ describe("subscription screen helpers", () => { expect( getFirstRechargeOfferView({ isFirstRecharge: true, - discountPercent: 50, vipPlans: vipViews, coinPlans: coinViews, }), @@ -182,6 +181,13 @@ describe("subscription screen helpers", () => { badgeText: "50% OFF", 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", () => { diff --git a/src/app/subscription/subscription-screen.helpers.ts b/src/app/subscription/subscription-screen.helpers.ts index 19061d07..cae8ce20 100644 --- a/src/app/subscription/subscription-screen.helpers.ts +++ b/src/app/subscription/subscription-screen.helpers.ts @@ -157,17 +157,17 @@ export function getDefaultSubscriptionPlanId(input: { export function getFirstRechargeOfferView(input: { isFirstRecharge: boolean; - discountPercent?: number | null; vipPlans: readonly VipOfferPlanView[]; coinPlans: readonly CoinsOfferPlanView[]; }): FirstRechargeOfferView | null { + if (!input.isFirstRecharge) return null; + const promotedPlan = [...input.vipPlans, ...input.coinPlans].find( (plan) => plan.isFirstRechargeOffer, ); - if (!input.isFirstRecharge && !promotedPlan) return null; const discountPercent = - promotedPlan?.firstRechargeDiscountPercent ?? input.discountPercent ?? 50; + promotedPlan?.firstRechargeDiscountPercent ?? 50; const badgeText = `${discountPercent}% OFF`; return { badgeText, diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 0cc5f904..d4274595 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -77,16 +77,10 @@ export function SubscriptionScreen({ () => getFirstRechargeOfferView({ isFirstRecharge: payment.isFirstRecharge, - discountPercent: payment.firstRechargeOffer?.discountPercent, vipPlans: vipOfferPlans, coinPlans: directCoinsPlans, }), - [ - directCoinsPlans, - payment.firstRechargeOffer?.discountPercent, - payment.isFirstRecharge, - vipOfferPlans, - ], + [directCoinsPlans, payment.isFirstRecharge, vipOfferPlans], ); const selectedPlan = findSelectedSubscriptionPlan({ diff --git a/src/data/repositories/interfaces/ipayment_repository.ts b/src/data/repositories/interfaces/ipayment_repository.ts index 18416390..8262e5d5 100644 --- a/src/data/repositories/interfaces/ipayment_repository.ts +++ b/src/data/repositories/interfaces/ipayment_repository.ts @@ -20,6 +20,9 @@ export interface IPaymentRepository { /** 获取本地缓存套餐列表。 */ getCachedPlans(): Promise>; + /** 清除本地缓存套餐列表。 */ + clearCachedPlans(): Promise>; + /** 获取会员套餐列表。 */ getVipPlans(): Promise>; diff --git a/src/data/repositories/payment_repository.ts b/src/data/repositories/payment_repository.ts index ab26a6ce..70e376ca 100644 --- a/src/data/repositories/payment_repository.ts +++ b/src/data/repositories/payment_repository.ts @@ -41,6 +41,14 @@ export class PaymentRepository implements IPaymentRepository { }); } + /** 清除本地缓存套餐列表。 */ + async clearCachedPlans(): Promise> { + return Result.wrap(async () => { + const result = await PaymentPlansStorage.clearPlans(); + if (Result.isErr(result)) throw result.error; + }); + } + /** 获取会员套餐列表。 */ async getVipPlans(): Promise> { return Result.wrap(async () => { diff --git a/src/stores/payment/__tests__/payment-machine.test.ts b/src/stores/payment/__tests__/payment-machine.test.ts index 12580b27..032c0e16 100644 --- a/src/stores/payment/__tests__/payment-machine.test.ts +++ b/src/stores/payment/__tests__/payment-machine.test.ts @@ -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( + 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((resolve) => { diff --git a/src/stores/payment/payment-context.tsx b/src/stores/payment/payment-context.tsx index 49be932a..75f52dad 100644 --- a/src/stores/payment/payment-context.tsx +++ b/src/stores/payment/payment-context.tsx @@ -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, diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index cb2e3c7e..8d1df7f1 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -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" }; diff --git a/src/stores/payment/payment-machine.helpers.ts b/src/stores/payment/payment-machine.helpers.ts index 2102c4fe..0b8341ee 100644 --- a/src/stores/payment/payment-machine.helpers.ts +++ b/src/stores/payment/payment-machine.helpers.ts @@ -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, }; } diff --git a/src/stores/payment/payment-machine.ts b/src/stores/payment/payment-machine.ts index 493f1184..5993b52b 100644 --- a/src/stores/payment/payment-machine.ts +++ b/src/stores/payment/payment-machine.ts @@ -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 }) => ({ diff --git a/src/stores/payment/payment-state.ts b/src/stores/payment/payment-state.ts index b73509ff..05d5afb8 100644 --- a/src/stores/payment/payment-state.ts +++ b/src/stores/payment/payment-state.ts @@ -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, diff --git a/src/stores/sync/payment-success-sync.tsx b/src/stores/sync/payment-success-sync.tsx index e1a19b3a..075c536f 100644 --- a/src/stores/sync/payment-success-sync.tsx +++ b/src/stores/sync/payment-success-sync.tsx @@ -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(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, ]);