fix(payment): consume first recharge offer after payment
This commit is contained in:
@@ -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