feat(payment): sync plans and add coins rules

This commit is contained in:
2026-06-29 14:11:50 +08:00
parent 274fe87ced
commit e372d58b4c
24 changed files with 1099 additions and 224 deletions
@@ -18,42 +18,55 @@ interface CreateOrderInput {
type CreateOrderSpy = (input: CreateOrderInput) => void;
const monthlyPlan = {
plan_id: "vip_monthly",
plan_name: "VIP Monthly",
order_type: "vip_monthly",
amount_cents: 999,
original_amount_cents: null,
daily_price_cents: 33,
planId: "vip_monthly",
planName: "VIP Monthly",
orderType: "vip_monthly",
vipDays: 30,
dolAmount: null,
creditBalance: 3000,
amountCents: 999,
originalAmountCents: null,
dailyPriceCents: 33,
currency: "usd",
pricing_tier: "T1",
vip_days: 30,
dol_amount: null,
};
const lifetimePlan = {
plan_id: "vip_lifetime",
plan_name: "VIP Lifetime",
order_type: "vip_lifetime",
amount_cents: 4999,
original_amount_cents: null,
daily_price_cents: null,
planId: "vip_lifetime",
planName: "VIP Lifetime",
orderType: "vip_lifetime",
vipDays: 3650,
dolAmount: null,
creditBalance: 0,
amountCents: 4999,
originalAmountCents: null,
dailyPriceCents: null,
currency: "usd",
pricing_tier: "T1",
vip_days: null,
dol_amount: null,
};
const creditPlan = {
plan_id: "credit_1000",
plan_name: "1000 Credits",
order_type: "credit_recharge",
amount_cents: 990,
original_amount_cents: null,
daily_price_cents: null,
planId: "dol_1000",
planName: "1000 Credits",
orderType: "dol",
vipDays: null,
dolAmount: 1000,
creditBalance: 1000,
amountCents: 990,
originalAmountCents: null,
dailyPriceCents: null,
currency: "usd",
};
const quarterlyPlan = {
planId: "vip_quarterly",
planName: "VIP Quarterly",
orderType: "vip_quarterly",
vipDays: 90,
dolAmount: null,
creditBalance: 9000,
amountCents: 2499,
originalAmountCents: null,
dailyPriceCents: 28,
currency: "usd",
pricing_tier: "T1",
vip_days: null,
dol_amount: 1000,
};
function createTestPaymentMachine(
@@ -67,7 +80,10 @@ function createTestPaymentMachine(
return paymentMachine.provide({
actors: {
loadPlans: fromPromise(async () =>
loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
async () => null,
),
refreshPlans: fromPromise(async () =>
PaymentPlansResponse.from({
plans: [monthlyPlan, lifetimePlan],
}),
@@ -113,6 +129,53 @@ describe("paymentMachine", () => {
actor.stop();
});
it("hydrates cached plans before refreshing with network plans", async () => {
let resolveRefresh!: (value: PaymentPlansResponse) => void;
const refreshPlansPromise = new Promise<PaymentPlansResponse>((resolve) => {
resolveRefresh = resolve;
});
const actor = createActor(
paymentMachine.provide({
actors: {
loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
async () =>
PaymentPlansResponse.from({
plans: [quarterlyPlan],
}),
),
refreshPlans: fromPromise<PaymentPlansResponse>(
async () => refreshPlansPromise,
),
},
}),
).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) =>
snapshot.matches("refreshingPlans"),
);
expect(actor.getSnapshot().context.plans[0]?.planId).toBe(
"vip_quarterly",
);
resolveRefresh(
PaymentPlansResponse.from({
plans: [monthlyPlan, creditPlan],
}),
);
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
const context = actor.getSnapshot().context;
expect(context.plans.map((plan) => plan.planId)).toEqual([
"vip_monthly",
"dol_1000",
]);
expect(context.selectedPlanId).toBe("vip_monthly");
actor.stop();
});
it("does not create an order before the agreement is accepted", async () => {
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
@@ -232,10 +295,10 @@ describe("paymentMachine", () => {
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentPlanSelected", planId: creditPlan.plan_id });
actor.send({ type: "PaymentPlanSelected", planId: creditPlan.planId });
const context = actor.getSnapshot().context;
expect(context.selectedPlanId).toBe("credit_1000");
expect(context.selectedPlanId).toBe("dol_1000");
expect(context.autoRenew).toBe(false);
actor.stop();
+4 -1
View File
@@ -59,7 +59,10 @@ export function PaymentProvider({ children }: PaymentProviderProps) {
orderStatus: state.context.orderStatus,
errorMessage: state.context.errorMessage,
launchNonce: state.context.launchNonce,
isLoadingPlans: state.matches("loadingPlans"),
isLoadingPlans:
state.matches("loadingCachedPlans") ||
state.matches("loadingPlans") ||
state.matches("refreshingPlans"),
isCreatingOrder: state.matches("creatingOrder"),
isPollingOrder:
state.matches("pollingOrder") || state.matches("waitingForPayment"),
+8 -1
View File
@@ -15,7 +15,14 @@ import { Result } from "@/utils";
const paymentRepo: IPaymentRepository = paymentRepository;
export const loadPaymentPlansActor = fromPromise<PaymentPlansResponse>(
export const loadCachedPaymentPlansActor =
fromPromise<PaymentPlansResponse | null>(async () => {
const result = await paymentRepo.getCachedPlans();
if (Result.isErr(result)) throw result.error;
return result.data;
});
export const refreshPaymentPlansActor = fromPromise<PaymentPlansResponse>(
async () => {
const result = await paymentRepo.getPlans();
if (Result.isErr(result)) throw result.error;
@@ -6,7 +6,12 @@ export function toPaymentErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function defaultAutoRenewForPlan(planId: string): boolean {
export function defaultAutoRenewForPlan(
planId: string,
plans: readonly PaymentPlan[] = [],
): boolean {
const plan = plans.find((item) => item.planId === planId);
if (plan?.dolAmount !== null && plan?.dolAmount !== undefined) return false;
return (
!planId.includes("lifetime") &&
!planId.startsWith("dol_") &&
+69 -10
View File
@@ -7,8 +7,9 @@ import type { PaymentEvent } from "./payment-events";
import { initialState, type PaymentState } from "./payment-state";
import {
createPaymentOrderActor,
loadPaymentPlansActor,
loadCachedPaymentPlansActor,
pollPaymentOrderStatusActor,
refreshPaymentPlansActor,
} from "./payment-machine.actors";
import {
defaultAutoRenewForPlan,
@@ -29,7 +30,8 @@ export const paymentMachine = setup({
events: {} as PaymentEvent,
},
actors: {
loadPlans: loadPaymentPlansActor,
loadCachedPlans: loadCachedPaymentPlansActor,
refreshPlans: refreshPaymentPlansActor,
createOrder: createPaymentOrderActor,
pollOrderStatus: pollPaymentOrderStatusActor,
},
@@ -41,13 +43,42 @@ export const paymentMachine = setup({
states: {
idle: {
on: {
PaymentInit: "loadingPlans",
PaymentInit: "loadingCachedPlans",
},
},
loadingCachedPlans: {
invoke: {
src: "loadCachedPlans",
onDone: [
{
guard: ({ event }) => (event.output?.plans.length ?? 0) > 0,
target: "refreshingPlans",
actions: assign(({ context, event }) => {
const plans = event.output?.plans ?? [];
const selectedPlanId =
context.selectedPlanId || getDefaultPlanId(plans);
return {
plans,
selectedPlanId,
autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
errorMessage: null,
};
}),
},
{
target: "loadingPlans",
},
],
onError: {
target: "loadingPlans",
},
},
},
loadingPlans: {
invoke: {
src: "loadPlans",
src: "refreshPlans",
onDone: {
target: "ready",
actions: assign(({ context, event }) => {
@@ -57,7 +88,35 @@ export const paymentMachine = setup({
return {
plans,
selectedPlanId,
autoRenew: defaultAutoRenewForPlan(selectedPlanId),
autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
errorMessage: null,
};
}),
},
onError: {
target: "ready",
actions: assign(({ event }) => ({
errorMessage: toPaymentErrorMessage(event.error),
})),
},
},
},
refreshingPlans: {
invoke: {
src: "refreshPlans",
onDone: {
target: "ready",
actions: assign(({ context, event }) => {
const plans = event.output.plans;
const selectedPlanId =
plans.some((plan) => plan.planId === context.selectedPlanId)
? context.selectedPlanId
: getDefaultPlanId(plans);
return {
plans,
selectedPlanId,
autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
errorMessage: null,
};
}),
@@ -73,12 +132,12 @@ export const paymentMachine = setup({
ready: {
on: {
PaymentInit: "loadingPlans",
PaymentInit: "refreshingPlans",
PaymentPlanSelected: {
actions: assign(({ event }) => ({
actions: assign(({ context, event }) => ({
...resetOrderState(),
selectedPlanId: event.planId,
autoRenew: defaultAutoRenewForPlan(event.planId),
autoRenew: defaultAutoRenewForPlan(event.planId, context.plans),
})),
},
PaymentPayChannelChanged: {
@@ -208,10 +267,10 @@ export const paymentMachine = setup({
on: {
PaymentPlanSelected: {
target: "ready",
actions: assign(({ event }) => ({
actions: assign(({ context, event }) => ({
...resetOrderState(),
selectedPlanId: event.planId,
autoRenew: defaultAutoRenewForPlan(event.planId),
autoRenew: defaultAutoRenewForPlan(event.planId, context.plans),
})),
},
PaymentPayChannelChanged: {