fix(payment): consume first recharge offer after payment
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user