feat(payment): sync plans and add coins rules
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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_") &&
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user