feat(payment): implement coffee tip plans and update payment flow
This commit is contained in:
@@ -29,24 +29,23 @@ function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
|
||||
}
|
||||
|
||||
describe("tip screen helpers", () => {
|
||||
it("uses the legacy tip coffee plan only for small", () => {
|
||||
it("matches the official small coffee plan", () => {
|
||||
const plan = makePlan({
|
||||
planId: "tip_coffee",
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
});
|
||||
|
||||
expect(findTipCoffeePlan([makePlan({}), plan], "small")).toBe(plan);
|
||||
expect(findTipCoffeePlan([plan], "medium")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches medium and large coffee plans independently", () => {
|
||||
const medium = makePlan({
|
||||
planId: "tip_coffee_medium",
|
||||
orderType: "tip_coffee_medium",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
orderType: "tip",
|
||||
amountCents: 999,
|
||||
});
|
||||
const large = makePlan({
|
||||
planId: "tip_coffee_large",
|
||||
orderType: "tip_coffee_large",
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
orderType: "tip",
|
||||
amountCents: 1999,
|
||||
});
|
||||
|
||||
@@ -57,7 +56,7 @@ describe("tip screen helpers", () => {
|
||||
it("does not infer a coffee tier from order type or amount", () => {
|
||||
const ambiguousPlan = makePlan({
|
||||
planId: "legacy_coffee",
|
||||
orderType: "tip_coffee_medium",
|
||||
orderType: "tip",
|
||||
amountCents: 999,
|
||||
});
|
||||
|
||||
|
||||
@@ -10,14 +10,7 @@ export function findTipCoffeePlan(
|
||||
coffeeType: TipCoffeeType,
|
||||
): PaymentPlan | null {
|
||||
const option = getTipCoffeeOption(coffeeType);
|
||||
|
||||
return (
|
||||
plans.find((plan) => plan.planId === option.planId) ??
|
||||
(coffeeType === "small"
|
||||
? (plans.find((plan) => plan.planId === "tip_coffee") ?? null)
|
||||
: null) ??
|
||||
null
|
||||
);
|
||||
return plans.find((plan) => plan.planId === option.planId) ?? null;
|
||||
}
|
||||
|
||||
export function formatTipPrice(
|
||||
|
||||
@@ -80,6 +80,7 @@ export function TipScreen({
|
||||
initialPayChannelAppliedRef.current = true;
|
||||
paymentDispatch({
|
||||
type: "PaymentInit",
|
||||
catalog: "tip",
|
||||
payChannel: resolvedInitialPayChannel,
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PaymentPlan, PaymentPlansResponse } from "@/data/dto/payment";
|
||||
import {
|
||||
PaymentPlan,
|
||||
PaymentPlansResponse,
|
||||
TipPaymentPlansResponse,
|
||||
} from "@/data/dto/payment";
|
||||
|
||||
describe("PaymentPlan", () => {
|
||||
it("parses the camelCase payment plan shape", () => {
|
||||
@@ -133,3 +137,35 @@ describe("PaymentPlansResponse", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TipPaymentPlansResponse", () => {
|
||||
it("keeps only fields used by the tip payment flow", () => {
|
||||
const response = TipPaymentPlansResponse.fromJson({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Small Coffee",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_small",
|
||||
description: "Buy Elio a small coffee",
|
||||
amountCents: 499,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.toJson()).toEqual({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Small Coffee",
|
||||
amountCents: 499,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
*/
|
||||
|
||||
export * from "./payment_plan";
|
||||
export * from "./tip_payment_plan";
|
||||
export * from "./request";
|
||||
export * from "./response";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./create_payment_order_response";
|
||||
export * from "./payment_order_status_response";
|
||||
export * from "./payment_plans_response";
|
||||
export * from "./tip_payment_plans_response";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
TipPaymentPlansResponseSchema,
|
||||
type TipPaymentPlansResponseData,
|
||||
type TipPaymentPlansResponseInput,
|
||||
} from "@/data/schemas/payment/tip_payment_plans_response";
|
||||
|
||||
import { TipPaymentPlan } from "../tip_payment_plan";
|
||||
|
||||
export class TipPaymentPlansResponse {
|
||||
declare readonly plans: TipPaymentPlan[];
|
||||
|
||||
private constructor(input: TipPaymentPlansResponseInput) {
|
||||
const data = TipPaymentPlansResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
plans: data.plans.map((plan) => TipPaymentPlan.from(plan)),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: TipPaymentPlansResponseInput): TipPaymentPlansResponse {
|
||||
return new TipPaymentPlansResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): TipPaymentPlansResponse {
|
||||
return TipPaymentPlansResponse.from(
|
||||
json as TipPaymentPlansResponseInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): TipPaymentPlansResponseData {
|
||||
return {
|
||||
plans: this.plans.map((plan) => plan.toJson()),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
TipPaymentPlanSchema,
|
||||
type TipPaymentPlanData,
|
||||
type TipPaymentPlanInput,
|
||||
} from "@/data/schemas/payment/tip_payment_plan";
|
||||
|
||||
export class TipPaymentPlan {
|
||||
declare readonly planId: string;
|
||||
declare readonly planName: string;
|
||||
declare readonly amountCents: number;
|
||||
declare readonly currency: string;
|
||||
|
||||
private constructor(input: TipPaymentPlanInput) {
|
||||
const data = TipPaymentPlanSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: TipPaymentPlanInput): TipPaymentPlan {
|
||||
return new TipPaymentPlan(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): TipPaymentPlan {
|
||||
return TipPaymentPlan.from(json as TipPaymentPlanInput);
|
||||
}
|
||||
|
||||
toJson(): TipPaymentPlanData {
|
||||
return TipPaymentPlanSchema.parse(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TipPaymentPlansResponse } from "@/data/dto/payment";
|
||||
import { PaymentRepository } from "@/data/repositories/payment_repository";
|
||||
import type { PaymentApi } from "@/data/services/api";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
describe("PaymentRepository", () => {
|
||||
it("adapts tip plans without caching them as subscription plans", async () => {
|
||||
const getTipPlans = vi.fn().mockResolvedValue(
|
||||
TipPaymentPlansResponse.from({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
planName: "Medium Coffee",
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const repository = new PaymentRepository({
|
||||
getTipPlans,
|
||||
} as unknown as PaymentApi);
|
||||
|
||||
const result = await repository.getTipPlans();
|
||||
|
||||
expect(getTipPlans).toHaveBeenCalledOnce();
|
||||
expect(Result.isOk(result) && result.data.plans[0]).toMatchObject({
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
planName: "Medium Coffee",
|
||||
orderType: "tip",
|
||||
amountCents: 999,
|
||||
currency: "USD",
|
||||
isFirstRechargeOffer: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,9 @@ export interface IPaymentRepository {
|
||||
/** 获取本地缓存套餐列表。 */
|
||||
getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
|
||||
|
||||
/** 获取咖啡打赏套餐列表。 */
|
||||
getTipPlans(): Promise<Result<PaymentPlansResponse>>;
|
||||
|
||||
/** 清除本地缓存套餐列表。 */
|
||||
clearCachedPlans(): Promise<Result<void>>;
|
||||
|
||||
|
||||
@@ -39,6 +39,27 @@ export class PaymentRepository implements IPaymentRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取咖啡打赏套餐列表,不写入通用套餐缓存。 */
|
||||
async getTipPlans(): Promise<Result<PaymentPlansResponse>> {
|
||||
return Result.wrap(async () => {
|
||||
const response = await this.api.getTipPlans();
|
||||
return PaymentPlansResponse.from({
|
||||
plans: response.plans.map((plan) => ({
|
||||
...plan.toJson(),
|
||||
orderType: "tip",
|
||||
vipDays: null,
|
||||
dolAmount: null,
|
||||
creditBalance: 0,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: null,
|
||||
promotionType: null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 清除本地缓存套餐列表。 */
|
||||
async clearCachedPlans(): Promise<Result<void>> {
|
||||
return Result.wrap(async () => {
|
||||
|
||||
@@ -7,3 +7,5 @@ export * from "./create_payment_order_response";
|
||||
export * from "./payment_order_status_response";
|
||||
export * from "./payment_plan";
|
||||
export * from "./payment_plans_response";
|
||||
export * from "./tip_payment_plan";
|
||||
export * from "./tip_payment_plans_response";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TipPaymentPlanSchema = z.object({
|
||||
planId: z.string(),
|
||||
planName: z.string(),
|
||||
amountCents: z.number(),
|
||||
currency: z.string(),
|
||||
});
|
||||
|
||||
export type TipPaymentPlanInput = z.input<typeof TipPaymentPlanSchema>;
|
||||
export type TipPaymentPlanData = z.output<typeof TipPaymentPlanSchema>;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { arrayOrEmpty } from "../nullable-defaults";
|
||||
import { TipPaymentPlanSchema } from "./tip_payment_plan";
|
||||
|
||||
export const TipPaymentPlansResponseSchema = z.object({
|
||||
plans: arrayOrEmpty(TipPaymentPlanSchema),
|
||||
});
|
||||
|
||||
export type TipPaymentPlansResponseInput = z.input<
|
||||
typeof TipPaymentPlansResponseSchema
|
||||
>;
|
||||
export type TipPaymentPlansResponseData = z.output<
|
||||
typeof TipPaymentPlansResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const httpClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../http_client", () => ({
|
||||
httpClient: httpClientMock,
|
||||
}));
|
||||
|
||||
import { PaymentApi } from "../payment_api";
|
||||
|
||||
describe("PaymentApi", () => {
|
||||
beforeEach(() => {
|
||||
httpClientMock.mockReset();
|
||||
});
|
||||
|
||||
it("loads public coffee tip plans from the dedicated endpoint", async () => {
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
planName: "Large Coffee",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_large",
|
||||
description: "Buy Elio a large coffee",
|
||||
amountCents: 1999,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await new PaymentApi().getTipPlans();
|
||||
|
||||
expect(httpClientMock).toHaveBeenCalledWith("/api/payment/tip-plans");
|
||||
expect(response.toJson()).toEqual({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
planName: "Large Coffee",
|
||||
amountCents: 1999,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,9 @@ export class ApiPath {
|
||||
/** 获取商品套餐列表 */
|
||||
static readonly paymentPlans = `${ApiPath._payment}/plans`;
|
||||
|
||||
/** 获取咖啡打赏套餐列表 */
|
||||
static readonly paymentTipPlans = `${ApiPath._payment}/tip-plans`;
|
||||
|
||||
// ============ 聊天相关 ============
|
||||
/** 发送消息 */
|
||||
static readonly chatSend = `${ApiPath._chat}/send`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CreatePaymentOrderResponse,
|
||||
PaymentOrderStatusResponse,
|
||||
PaymentPlansResponse,
|
||||
TipPaymentPlansResponse,
|
||||
} from "@/data/dto/payment";
|
||||
|
||||
import { ApiPath } from "./api_path";
|
||||
@@ -25,6 +26,16 @@ export class PaymentApi {
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取咖啡打赏套餐列表。 */
|
||||
async getTipPlans(): Promise<TipPaymentPlansResponse> {
|
||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||
ApiPath.paymentTipPlans,
|
||||
);
|
||||
return TipPaymentPlansResponse.fromJson(
|
||||
unwrap(env) as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付订单
|
||||
*/
|
||||
|
||||
@@ -21,15 +21,15 @@ describe("tip coffee configuration", () => {
|
||||
it("provides the configured plan and fallback price for each type", () => {
|
||||
expect(getTipCoffeeOption("small")).toMatchObject({
|
||||
amountCents: 499,
|
||||
planId: "tip_coffee_small",
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
});
|
||||
expect(getTipCoffeeOption("medium")).toMatchObject({
|
||||
amountCents: 999,
|
||||
planId: "tip_coffee_medium",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
});
|
||||
expect(getTipCoffeeOption("large")).toMatchObject({
|
||||
amountCents: 1999,
|
||||
planId: "tip_coffee_large",
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,19 +17,19 @@ const TIP_COFFEE_OPTIONS: Record<TipCoffeeType, TipCoffeeOption> = {
|
||||
type: "small",
|
||||
amountCents: 499,
|
||||
fallbackName: "Small Coffee",
|
||||
planId: "tip_coffee_small",
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
},
|
||||
medium: {
|
||||
type: "medium",
|
||||
amountCents: 999,
|
||||
fallbackName: "Medium Coffee",
|
||||
planId: "tip_coffee_medium",
|
||||
planId: "tip_coffee_usd_9_99",
|
||||
},
|
||||
large: {
|
||||
type: "large",
|
||||
amountCents: 1999,
|
||||
fallbackName: "Large Coffee",
|
||||
planId: "tip_coffee_large",
|
||||
planId: "tip_coffee_usd_19_99",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
MAX_ORDER_POLLING_MS,
|
||||
PAYMENT_TIMEOUT_ERROR_MESSAGE,
|
||||
} from "@/stores/payment/payment-machine.helpers";
|
||||
import type { PaymentPlanCatalog } from "@/stores/payment/payment-state";
|
||||
|
||||
interface PaymentPlansActorInput {
|
||||
catalog: PaymentPlanCatalog;
|
||||
}
|
||||
|
||||
interface CreateOrderInput {
|
||||
planId: string;
|
||||
@@ -73,6 +78,19 @@ const quarterlyPlan = {
|
||||
currency: "usd",
|
||||
};
|
||||
|
||||
const tipPlan = {
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Small Coffee",
|
||||
orderType: "tip",
|
||||
vipDays: null,
|
||||
dolAmount: null,
|
||||
creditBalance: 0,
|
||||
amountCents: 499,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
currency: "USD",
|
||||
};
|
||||
|
||||
function createTestPaymentMachine(
|
||||
overrides: Partial<{
|
||||
createOrderSpy: CreateOrderSpy;
|
||||
@@ -87,10 +105,16 @@ function createTestPaymentMachine(
|
||||
|
||||
return paymentMachine.provide({
|
||||
actors: {
|
||||
loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
|
||||
loadCachedPlans: fromPromise<
|
||||
PaymentPlansResponse | null,
|
||||
PaymentPlansActorInput
|
||||
>(
|
||||
async () => null,
|
||||
),
|
||||
refreshPlans: fromPromise(async () =>
|
||||
refreshPlans: fromPromise<
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansActorInput
|
||||
>(async () =>
|
||||
PaymentPlansResponse.from({
|
||||
plans: [monthlyPlan, lifetimePlan],
|
||||
}),
|
||||
@@ -145,14 +169,85 @@ describe("paymentMachine", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("loads the tip catalog and disables auto renew", async () => {
|
||||
const loadCachedCatalog = vi.fn();
|
||||
const refreshCatalog = vi.fn();
|
||||
const createOrderSpy = vi.fn<CreateOrderSpy>();
|
||||
const actor = createActor(
|
||||
paymentMachine.provide({
|
||||
actors: {
|
||||
loadCachedPlans: fromPromise<
|
||||
PaymentPlansResponse | null,
|
||||
PaymentPlansActorInput
|
||||
>(async ({ input }) => {
|
||||
loadCachedCatalog(input.catalog);
|
||||
return null;
|
||||
}),
|
||||
refreshPlans: fromPromise<
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansActorInput
|
||||
>(async ({ input }) => {
|
||||
refreshCatalog(input.catalog);
|
||||
return PaymentPlansResponse.from({ plans: [tipPlan] });
|
||||
}),
|
||||
createOrder: fromPromise<
|
||||
CreatePaymentOrderResponse,
|
||||
CreateOrderInput
|
||||
>(async ({ input }) => {
|
||||
createOrderSpy(input);
|
||||
return CreatePaymentOrderResponse.from({
|
||||
orderId: "pay_tip_001",
|
||||
payParams: { url: "https://checkout.example/tip" },
|
||||
});
|
||||
}),
|
||||
pollOrderStatus: fromPromise(async () =>
|
||||
PaymentOrderStatusResponse.from({
|
||||
orderId: "pay_tip_001",
|
||||
status: "paid",
|
||||
orderType: "tip",
|
||||
planId: tipPlan.planId,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "PaymentInit", catalog: "tip" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
expect(loadCachedCatalog).toHaveBeenCalledWith("tip");
|
||||
expect(refreshCatalog).toHaveBeenCalledWith("tip");
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
planCatalog: "tip",
|
||||
selectedPlanId: "tip_coffee_usd_4_99",
|
||||
autoRenew: false,
|
||||
});
|
||||
|
||||
actor.send({ type: "PaymentCreateOrderSubmitted" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||
expect(createOrderSpy).toHaveBeenCalledWith({
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
payChannel: "stripe",
|
||||
autoRenew: false,
|
||||
});
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps first recharge flag and plan metadata from the plans response", async () => {
|
||||
const actor = createActor(
|
||||
paymentMachine.provide({
|
||||
actors: {
|
||||
loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
|
||||
loadCachedPlans: fromPromise<
|
||||
PaymentPlansResponse | null,
|
||||
PaymentPlansActorInput
|
||||
>(
|
||||
async () => null,
|
||||
),
|
||||
refreshPlans: fromPromise(async () =>
|
||||
refreshPlans: fromPromise<
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansActorInput
|
||||
>(async () =>
|
||||
PaymentPlansResponse.from({
|
||||
isFirstRecharge: true,
|
||||
firstRechargeOffer: {
|
||||
@@ -194,10 +289,16 @@ describe("paymentMachine", () => {
|
||||
const actor = createActor(
|
||||
paymentMachine.provide({
|
||||
actors: {
|
||||
loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
|
||||
loadCachedPlans: fromPromise<
|
||||
PaymentPlansResponse | null,
|
||||
PaymentPlansActorInput
|
||||
>(
|
||||
async () => null,
|
||||
),
|
||||
refreshPlans: fromPromise(async () =>
|
||||
refreshPlans: fromPromise<
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansActorInput
|
||||
>(async () =>
|
||||
PaymentPlansResponse.from({
|
||||
isFirstRecharge: true,
|
||||
firstRechargeOffer: {
|
||||
@@ -243,10 +344,16 @@ describe("paymentMachine", () => {
|
||||
const actor = createActor(
|
||||
paymentMachine.provide({
|
||||
actors: {
|
||||
loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
|
||||
loadCachedPlans: fromPromise<
|
||||
PaymentPlansResponse | null,
|
||||
PaymentPlansActorInput
|
||||
>(
|
||||
async () => null,
|
||||
),
|
||||
refreshPlans: fromPromise(async () =>
|
||||
refreshPlans: fromPromise<
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansActorInput
|
||||
>(async () =>
|
||||
PaymentPlansResponse.from({
|
||||
isFirstRecharge: false,
|
||||
plans: [
|
||||
@@ -285,13 +392,19 @@ describe("paymentMachine", () => {
|
||||
const actor = createActor(
|
||||
paymentMachine.provide({
|
||||
actors: {
|
||||
loadCachedPlans: fromPromise<PaymentPlansResponse | null>(
|
||||
loadCachedPlans: fromPromise<
|
||||
PaymentPlansResponse | null,
|
||||
PaymentPlansActorInput
|
||||
>(
|
||||
async () =>
|
||||
PaymentPlansResponse.from({
|
||||
plans: [quarterlyPlan],
|
||||
}),
|
||||
),
|
||||
refreshPlans: fromPromise<PaymentPlansResponse>(
|
||||
refreshPlans: fromPromise<
|
||||
PaymentPlansResponse,
|
||||
PaymentPlansActorInput
|
||||
>(
|
||||
async () => refreshPlansPromise,
|
||||
),
|
||||
},
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
* Payment 状态机:事件联合
|
||||
*/
|
||||
import type { PayChannel } from "@/data/dto/payment";
|
||||
import type { PaymentPlanCatalog } from "./payment-state";
|
||||
|
||||
export type PaymentEvent =
|
||||
| { type: "PaymentInit"; payChannel?: PayChannel }
|
||||
| {
|
||||
type: "PaymentInit";
|
||||
payChannel?: PayChannel;
|
||||
catalog?: PaymentPlanCatalog;
|
||||
}
|
||||
| { type: "PaymentPlanSelected"; planId: string }
|
||||
| { type: "PaymentPayChannelChanged"; payChannel: PayChannel }
|
||||
| { type: "PaymentAutoRenewChanged"; autoRenew: boolean }
|
||||
|
||||
@@ -12,18 +12,30 @@ import {
|
||||
import { getPaymentRepository } from "@/data/repositories/payment_repository";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
import type { PaymentPlanCatalog } from "./payment-state";
|
||||
|
||||
export const loadCachedPaymentPlansActor =
|
||||
fromPromise<PaymentPlansResponse | null>(async () => {
|
||||
fromPromise<
|
||||
PaymentPlansResponse | null,
|
||||
{ catalog: PaymentPlanCatalog }
|
||||
>(async ({ input }) => {
|
||||
if (input.catalog === "tip") return null;
|
||||
const paymentRepo = getPaymentRepository();
|
||||
const result = await paymentRepo.getCachedPlans();
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
});
|
||||
|
||||
export const refreshPaymentPlansActor = fromPromise<PaymentPlansResponse>(
|
||||
async () => {
|
||||
export const refreshPaymentPlansActor = fromPromise<
|
||||
PaymentPlansResponse,
|
||||
{ catalog: PaymentPlanCatalog }
|
||||
>(
|
||||
async ({ input }) => {
|
||||
const paymentRepo = getPaymentRepository();
|
||||
const result = await paymentRepo.getPlans();
|
||||
const result =
|
||||
input.catalog === "tip"
|
||||
? await paymentRepo.getTipPlans()
|
||||
: await paymentRepo.getPlans();
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
return result.data;
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ export function defaultAutoRenewForPlan(
|
||||
plans: readonly PaymentPlan[] = [],
|
||||
): boolean {
|
||||
const plan = plans.find((item) => item.planId === planId);
|
||||
if (plan?.orderType === "tip") return false;
|
||||
if (plan?.dolAmount !== null && plan?.dolAmount !== undefined) return false;
|
||||
return (
|
||||
!planId.includes("lifetime") &&
|
||||
|
||||
@@ -49,9 +49,10 @@ export const paymentMachine = setup({
|
||||
on: {
|
||||
PaymentInit: {
|
||||
target: "loadingCachedPlans",
|
||||
actions: assign(({ event }) =>
|
||||
event.payChannel ? { payChannel: event.payChannel } : {},
|
||||
),
|
||||
actions: assign(({ context, event }) => ({
|
||||
planCatalog: event.catalog ?? context.planCatalog,
|
||||
...(event.payChannel ? { payChannel: event.payChannel } : {}),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -59,6 +60,7 @@ export const paymentMachine = setup({
|
||||
loadingCachedPlans: {
|
||||
invoke: {
|
||||
src: "loadCachedPlans",
|
||||
input: ({ context }) => ({ catalog: context.planCatalog }),
|
||||
onDone: [
|
||||
{
|
||||
guard: ({ event }) => (event.output?.plans.length ?? 0) > 0,
|
||||
@@ -84,6 +86,7 @@ export const paymentMachine = setup({
|
||||
loadingPlans: {
|
||||
invoke: {
|
||||
src: "refreshPlans",
|
||||
input: ({ context }) => ({ catalog: context.planCatalog }),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => {
|
||||
@@ -105,6 +108,7 @@ export const paymentMachine = setup({
|
||||
refreshingPlans: {
|
||||
invoke: {
|
||||
src: "refreshPlans",
|
||||
input: ({ context }) => ({ catalog: context.planCatalog }),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: assign(({ context, event }) => {
|
||||
@@ -125,7 +129,25 @@ export const paymentMachine = setup({
|
||||
|
||||
ready: {
|
||||
on: {
|
||||
PaymentInit: "refreshingPlans",
|
||||
PaymentInit: {
|
||||
target: "refreshingPlans",
|
||||
actions: assign(({ context, event }) => {
|
||||
const planCatalog = event.catalog ?? context.planCatalog;
|
||||
const catalogChanged = planCatalog !== context.planCatalog;
|
||||
return {
|
||||
planCatalog,
|
||||
...(event.payChannel ? { payChannel: event.payChannel } : {}),
|
||||
...(catalogChanged
|
||||
? {
|
||||
plans: [],
|
||||
selectedPlanId: "",
|
||||
isFirstRecharge: false,
|
||||
autoRenew: planCatalog !== "tip",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
PaymentPlanSelected: {
|
||||
actions: assign(({ context, event }) => ({
|
||||
...selectPlanState(event.planId, context.plans),
|
||||
|
||||
@@ -7,7 +7,10 @@ import type {
|
||||
PaymentPlan,
|
||||
} from "@/data/dto/payment";
|
||||
|
||||
export type PaymentPlanCatalog = "default" | "tip";
|
||||
|
||||
export interface PaymentState {
|
||||
planCatalog: PaymentPlanCatalog;
|
||||
plans: PaymentPlan[];
|
||||
isFirstRecharge: boolean;
|
||||
selectedPlanId: string;
|
||||
@@ -23,6 +26,7 @@ export interface PaymentState {
|
||||
}
|
||||
|
||||
export const initialState: PaymentState = {
|
||||
planCatalog: "default",
|
||||
plans: [],
|
||||
isFirstRecharge: false,
|
||||
selectedPlanId: "",
|
||||
|
||||
Reference in New Issue
Block a user