fix(payment): consume first recharge offer after payment
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface IPaymentRepository {
|
||||
/** 获取本地缓存套餐列表。 */
|
||||
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
|
||||
|
||||
/** 清除本地缓存套餐列表。 */
|
||||
clearCachedPlans(): Promise<Result<void>>;
|
||||
|
||||
/** 获取会员套餐列表。 */
|
||||
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[]>> {
|
||||
return Result.wrap(async () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user