feat(payment): implement coffee tip plans and update payment flow

This commit is contained in:
2026-07-14 11:48:46 +08:00
parent f9c15bd91f
commit 3a4f24cb06
30 changed files with 632 additions and 56 deletions
-10
View File
@@ -36,16 +36,6 @@ description: Sync this Next.js frontend with backend API documentation
| Mock | `src/data/mock/<module>/**` | 模拟请求 / 响应数据 | | Mock | `src/data/mock/<module>/**` | 模拟请求 / 响应数据 |
| Tests | `**/__tests__/*.test.ts` | DTO、helper、machine transition 测试 | | Tests | `**/__tests__/*.test.ts` | DTO、helper、machine transition 测试 |
## 支持模块
| 模块 | API 路径 | 主要文件 |
| --- | --- | --- |
| `auth` | `/api/auth/*`, `/api/verify/*` | `auth_api.ts`, `auth_repository.ts`, `src/stores/auth/*` |
| `chat` | `/api/chat/*` | `chat_api.ts`, `chat_repository.ts`, `src/stores/chat/*` |
| `user` | `/api/user/*` | `user_api.ts`, `user_repository.ts`, `src/stores/user/*` |
| `payment` | `/api/payment/*` | `payment_api.ts`, `payment_repository.ts`, `src/stores/payment/*` |
| `metrics` | `/api/metrics/*` | `metrics_api.ts`, `metrics_repository.ts` |
## 后端文档来源 ## 后端文档来源
优先读取用户指定的文档或文件片段。 优先读取用户指定的文档或文件片段。
+132
View File
@@ -0,0 +1,132 @@
# Cozsweet 咖啡打赏接口
## 1. 用途
前端展示三个一次性咖啡打赏档位,并复用现有支付建单、支付跳转和订单轮询流程。
## 2. 接口地址
| 功能 | 方法 | 路径 | 鉴权 |
| --- | --- | --- | --- |
| 查询打赏品类 | GET | `/api/payment/tip-plans` | 不需要 |
| 创建打赏订单 | POST | `/api/payment/create-order` | Bearer Token |
| 查询订单状态 | GET | `/api/payment/order-status?order_id=...` | Bearer Token |
## 3. 查询打赏品类
```bash
curl 'https://api.banlv-ai.com/api/payment/tip-plans'
```
成功响应:
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"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
},
{
"planId": "tip_coffee_usd_9_99",
"planName": "Medium Coffee",
"orderType": "tip",
"tipType": "coffee_medium",
"description": "Buy Elio a medium coffee",
"amountCents": 999,
"currency": "USD",
"autoRenew": false,
"isFirstRechargeOffer": false,
"firstRechargeDiscountPercent": 0
},
{
"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
}
]
}
}
```
当前三个档位如下,顺序就是接口返回顺序:
| `planId` | `tipType` | 价格 |
| --- | --- | ---: |
| `tip_coffee_usd_4_99` | `coffee_small` | USD 4.99 |
| `tip_coffee_usd_9_99` | `coffee_medium` | USD 9.99 |
| `tip_coffee_usd_19_99` | `coffee_large` | USD 19.99 |
三个档位都不按国家换币、不参与首充半价,也不发会员或积分。前端必须使用接口返回的 `planId`,不要根据价格自行拼接。
## 4. 创建打赏订单
```bash
curl -X POST 'https://api.banlv-ai.com/api/payment/create-order' \
-H 'Authorization: Bearer <TOKEN>' \
-H 'Content-Type: application/json' \
-d '{
"planId": "tip_coffee_usd_9_99",
"payChannel": "stripe",
"autoRenew": false
}'
```
| 字段 | 类型 | 必填 | 固定/可选值 | 说明 |
| --- | --- | --- | --- | --- |
| `planId` | string | 是 | 上表三个 ID 之一 | 咖啡打赏产品 ID。 |
| `payChannel` | string | 是 | `stripe` / `ezpay` | 支付渠道。 |
| `autoRenew` | boolean | 是 | `false` | 打赏是一次性支付,必须传 false。 |
成功响应与现有充值相同:
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"orderId": "pay_xxx",
"payParams": {
"url": "https://checkout.example/..."
},
"expiresAt": "2026-07-14T10:30:00+00:00",
"expiresInSeconds": 1800
}
}
```
前端必须打开 `payParams` 中的支付 URL,并每 3-5 秒轮询订单状态。
## 5. 订单状态与 WebSocket
支付完成后:
```json
{
"orderId": "pay_xxx",
"status": "paid",
"orderType": "tip",
"planId": "tip_coffee_usd_9_99",
"creditsAdded": 0
}
```
+4 -4
View File
@@ -57,9 +57,9 @@ https://<APP_HOST>/external-entry?target=private-room
| `coffee_type` | 展示名称 | 价格 | 后端套餐 `planId` | | `coffee_type` | 展示名称 | 价格 | 后端套餐 `planId` |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `small` | Small Coffee | US$4.99 | `tip_coffee_small` | | `small` | Small Coffee | US$4.99 | `tip_coffee_usd_4_99` |
| `medium` | Medium Coffee | US$9.99 | `tip_coffee_medium` | | `medium` | Medium Coffee | US$9.99 | `tip_coffee_usd_9_99` |
| `large` | Large Coffee | US$19.99 | `tip_coffee_large` | | `large` | Large Coffee | US$19.99 | `tip_coffee_usd_19_99` |
分别进入三种咖啡打赏页面: 分别进入三种咖啡打赏页面:
@@ -71,7 +71,7 @@ https://<APP_HOST>/external-entry?target=tip&coffee_type=large
外部入口会将它们规范化为 `/tip?coffee_type=<type>``coffee_type` 会在登录、Stripe 回跳和 Ezpay 回跳期间保留,确保支付流程始终使用最初选择的咖啡档位。 外部入口会将它们规范化为 `/tip?coffee_type=<type>``coffee_type` 会在登录、Stripe 回跳和 Ezpay 回跳期间保留,确保支付流程始终使用最初选择的咖啡档位。
实际创建订单时,前端只使用对应 `planId` 的后端套餐;Small 额外兼容旧套餐 `tip_coffee`。最终支付金额以后端返回的套餐数据为准,若找不到对应套餐,页面会禁止下单,避免使用错误档位。 实际创建订单时,前端通过 `/api/payment/tip-plans` 获取套餐,并只使用接口返回的对应 `planId`。最终支付金额以后端返回的套餐数据为准,若找不到对应套餐,页面会禁止下单,避免使用错误档位。
## 促销入口 ## 促销入口
+5
View File
@@ -18,6 +18,7 @@ import {
paidVoiceHistoryResponse, paidVoiceHistoryResponse,
paidPaymentOrderStatusResponse, paidPaymentOrderStatusResponse,
paymentPlansResponse, paymentPlansResponse,
tipPaymentPlansResponse,
refreshedEmailLoginResponse, refreshedEmailLoginResponse,
refreshedGuestLoginResponse, refreshedGuestLoginResponse,
userEntitlementsResponse, userEntitlementsResponse,
@@ -189,6 +190,10 @@ export async function mockCoreApis(
await route.fulfill({ json: apiEnvelope(paymentPlansResponse) }); await route.fulfill({ json: apiEnvelope(paymentPlansResponse) });
}); });
await page.route("**/api/payment/tip-plans**", async (route) => {
await route.fulfill({ json: apiEnvelope(tipPaymentPlansResponse) });
});
await page.route("**/api/payment/create-order", async (route) => { await page.route("**/api/payment/create-order", async (route) => {
await route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) }); await route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) });
}); });
+41
View File
@@ -336,6 +336,47 @@ export const paymentPlansResponse = {
], ],
}; };
export const tipPaymentPlansResponse = {
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,
},
{
planId: "tip_coffee_usd_9_99",
planName: "Medium Coffee",
orderType: "tip",
tipType: "coffee_medium",
description: "Buy Elio a medium coffee",
amountCents: 999,
currency: "USD",
autoRenew: false,
isFirstRechargeOffer: false,
firstRechargeDiscountPercent: 0,
},
{
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,
},
],
};
export const paymentOrderId = "order_e2e_vip_monthly"; export const paymentOrderId = "order_e2e_vip_monthly";
export const createPaymentOrderResponse = { export const createPaymentOrderResponse = {
@@ -29,24 +29,23 @@ function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
} }
describe("tip screen helpers", () => { describe("tip screen helpers", () => {
it("uses the legacy tip coffee plan only for small", () => { it("matches the official small coffee plan", () => {
const plan = makePlan({ const plan = makePlan({
planId: "tip_coffee", planId: "tip_coffee_usd_4_99",
}); });
expect(findTipCoffeePlan([makePlan({}), plan], "small")).toBe(plan); expect(findTipCoffeePlan([makePlan({}), plan], "small")).toBe(plan);
expect(findTipCoffeePlan([plan], "medium")).toBeNull();
}); });
it("matches medium and large coffee plans independently", () => { it("matches medium and large coffee plans independently", () => {
const medium = makePlan({ const medium = makePlan({
planId: "tip_coffee_medium", planId: "tip_coffee_usd_9_99",
orderType: "tip_coffee_medium", orderType: "tip",
amountCents: 999, amountCents: 999,
}); });
const large = makePlan({ const large = makePlan({
planId: "tip_coffee_large", planId: "tip_coffee_usd_19_99",
orderType: "tip_coffee_large", orderType: "tip",
amountCents: 1999, amountCents: 1999,
}); });
@@ -57,7 +56,7 @@ describe("tip screen helpers", () => {
it("does not infer a coffee tier from order type or amount", () => { it("does not infer a coffee tier from order type or amount", () => {
const ambiguousPlan = makePlan({ const ambiguousPlan = makePlan({
planId: "legacy_coffee", planId: "legacy_coffee",
orderType: "tip_coffee_medium", orderType: "tip",
amountCents: 999, amountCents: 999,
}); });
+1 -8
View File
@@ -10,14 +10,7 @@ export function findTipCoffeePlan(
coffeeType: TipCoffeeType, coffeeType: TipCoffeeType,
): PaymentPlan | null { ): PaymentPlan | null {
const option = getTipCoffeeOption(coffeeType); const option = getTipCoffeeOption(coffeeType);
return plans.find((plan) => plan.planId === option.planId) ?? null;
return (
plans.find((plan) => plan.planId === option.planId) ??
(coffeeType === "small"
? (plans.find((plan) => plan.planId === "tip_coffee") ?? null)
: null) ??
null
);
} }
export function formatTipPrice( export function formatTipPrice(
+1
View File
@@ -80,6 +80,7 @@ export function TipScreen({
initialPayChannelAppliedRef.current = true; initialPayChannelAppliedRef.current = true;
paymentDispatch({ paymentDispatch({
type: "PaymentInit", type: "PaymentInit",
catalog: "tip",
payChannel: resolvedInitialPayChannel, payChannel: resolvedInitialPayChannel,
}); });
return; return;
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { PaymentPlan, PaymentPlansResponse } from "@/data/dto/payment"; import {
PaymentPlan,
PaymentPlansResponse,
TipPaymentPlansResponse,
} from "@/data/dto/payment";
describe("PaymentPlan", () => { describe("PaymentPlan", () => {
it("parses the camelCase payment plan shape", () => { 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",
},
],
});
});
});
+1
View File
@@ -3,5 +3,6 @@
*/ */
export * from "./payment_plan"; export * from "./payment_plan";
export * from "./tip_payment_plan";
export * from "./request"; export * from "./request";
export * from "./response"; export * from "./response";
+1
View File
@@ -1,3 +1,4 @@
export * from "./create_payment_order_response"; export * from "./create_payment_order_response";
export * from "./payment_order_status_response"; export * from "./payment_order_status_response";
export * from "./payment_plans_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()),
};
}
}
+30
View File
@@ -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>>; getCachedPlans(): Promise<Result<PaymentPlansResponse | null>>;
/** 获取咖啡打赏套餐列表。 */
getTipPlans(): Promise<Result<PaymentPlansResponse>>;
/** 清除本地缓存套餐列表。 */ /** 清除本地缓存套餐列表。 */
clearCachedPlans(): Promise<Result<void>>; 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>> { async clearCachedPlans(): Promise<Result<void>> {
return Result.wrap(async () => { return Result.wrap(async () => {
+2
View File
@@ -7,3 +7,5 @@ export * from "./create_payment_order_response";
export * from "./payment_order_status_response"; export * from "./payment_order_status_response";
export * from "./payment_plan"; export * from "./payment_plan";
export * from "./payment_plans_response"; 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",
},
],
});
});
});
+3
View File
@@ -67,6 +67,9 @@ export class ApiPath {
/** 获取商品套餐列表 */ /** 获取商品套餐列表 */
static readonly paymentPlans = `${ApiPath._payment}/plans`; static readonly paymentPlans = `${ApiPath._payment}/plans`;
/** 获取咖啡打赏套餐列表 */
static readonly paymentTipPlans = `${ApiPath._payment}/tip-plans`;
// ============ 聊天相关 ============ // ============ 聊天相关 ============
/** 发送消息 */ /** 发送消息 */
static readonly chatSend = `${ApiPath._chat}/send`; static readonly chatSend = `${ApiPath._chat}/send`;
+11
View File
@@ -8,6 +8,7 @@ import {
CreatePaymentOrderResponse, CreatePaymentOrderResponse,
PaymentOrderStatusResponse, PaymentOrderStatusResponse,
PaymentPlansResponse, PaymentPlansResponse,
TipPaymentPlansResponse,
} from "@/data/dto/payment"; } from "@/data/dto/payment";
import { ApiPath } from "./api_path"; 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>,
);
}
/** /**
* 创建支付订单 * 创建支付订单
*/ */
+3 -3
View File
@@ -21,15 +21,15 @@ describe("tip coffee configuration", () => {
it("provides the configured plan and fallback price for each type", () => { it("provides the configured plan and fallback price for each type", () => {
expect(getTipCoffeeOption("small")).toMatchObject({ expect(getTipCoffeeOption("small")).toMatchObject({
amountCents: 499, amountCents: 499,
planId: "tip_coffee_small", planId: "tip_coffee_usd_4_99",
}); });
expect(getTipCoffeeOption("medium")).toMatchObject({ expect(getTipCoffeeOption("medium")).toMatchObject({
amountCents: 999, amountCents: 999,
planId: "tip_coffee_medium", planId: "tip_coffee_usd_9_99",
}); });
expect(getTipCoffeeOption("large")).toMatchObject({ expect(getTipCoffeeOption("large")).toMatchObject({
amountCents: 1999, amountCents: 1999,
planId: "tip_coffee_large", planId: "tip_coffee_usd_19_99",
}); });
}); });
+3 -3
View File
@@ -17,19 +17,19 @@ const TIP_COFFEE_OPTIONS: Record<TipCoffeeType, TipCoffeeOption> = {
type: "small", type: "small",
amountCents: 499, amountCents: 499,
fallbackName: "Small Coffee", fallbackName: "Small Coffee",
planId: "tip_coffee_small", planId: "tip_coffee_usd_4_99",
}, },
medium: { medium: {
type: "medium", type: "medium",
amountCents: 999, amountCents: 999,
fallbackName: "Medium Coffee", fallbackName: "Medium Coffee",
planId: "tip_coffee_medium", planId: "tip_coffee_usd_9_99",
}, },
large: { large: {
type: "large", type: "large",
amountCents: 1999, amountCents: 1999,
fallbackName: "Large Coffee", fallbackName: "Large Coffee",
planId: "tip_coffee_large", planId: "tip_coffee_usd_19_99",
}, },
}; };
@@ -12,6 +12,11 @@ import {
MAX_ORDER_POLLING_MS, MAX_ORDER_POLLING_MS,
PAYMENT_TIMEOUT_ERROR_MESSAGE, PAYMENT_TIMEOUT_ERROR_MESSAGE,
} from "@/stores/payment/payment-machine.helpers"; } from "@/stores/payment/payment-machine.helpers";
import type { PaymentPlanCatalog } from "@/stores/payment/payment-state";
interface PaymentPlansActorInput {
catalog: PaymentPlanCatalog;
}
interface CreateOrderInput { interface CreateOrderInput {
planId: string; planId: string;
@@ -73,6 +78,19 @@ const quarterlyPlan = {
currency: "usd", 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( function createTestPaymentMachine(
overrides: Partial<{ overrides: Partial<{
createOrderSpy: CreateOrderSpy; createOrderSpy: CreateOrderSpy;
@@ -87,10 +105,16 @@ function createTestPaymentMachine(
return paymentMachine.provide({ return paymentMachine.provide({
actors: { actors: {
loadCachedPlans: fromPromise<PaymentPlansResponse | null>( loadCachedPlans: fromPromise<
PaymentPlansResponse | null,
PaymentPlansActorInput
>(
async () => null, async () => null,
), ),
refreshPlans: fromPromise(async () => refreshPlans: fromPromise<
PaymentPlansResponse,
PaymentPlansActorInput
>(async () =>
PaymentPlansResponse.from({ PaymentPlansResponse.from({
plans: [monthlyPlan, lifetimePlan], plans: [monthlyPlan, lifetimePlan],
}), }),
@@ -145,14 +169,85 @@ describe("paymentMachine", () => {
actor.stop(); 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 () => { 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: {
loadCachedPlans: fromPromise<PaymentPlansResponse | null>( loadCachedPlans: fromPromise<
PaymentPlansResponse | null,
PaymentPlansActorInput
>(
async () => null, async () => null,
), ),
refreshPlans: fromPromise(async () => refreshPlans: fromPromise<
PaymentPlansResponse,
PaymentPlansActorInput
>(async () =>
PaymentPlansResponse.from({ PaymentPlansResponse.from({
isFirstRecharge: true, isFirstRecharge: true,
firstRechargeOffer: { firstRechargeOffer: {
@@ -194,10 +289,16 @@ describe("paymentMachine", () => {
const actor = createActor( const actor = createActor(
paymentMachine.provide({ paymentMachine.provide({
actors: { actors: {
loadCachedPlans: fromPromise<PaymentPlansResponse | null>( loadCachedPlans: fromPromise<
PaymentPlansResponse | null,
PaymentPlansActorInput
>(
async () => null, async () => null,
), ),
refreshPlans: fromPromise(async () => refreshPlans: fromPromise<
PaymentPlansResponse,
PaymentPlansActorInput
>(async () =>
PaymentPlansResponse.from({ PaymentPlansResponse.from({
isFirstRecharge: true, isFirstRecharge: true,
firstRechargeOffer: { firstRechargeOffer: {
@@ -243,10 +344,16 @@ describe("paymentMachine", () => {
const actor = createActor( const actor = createActor(
paymentMachine.provide({ paymentMachine.provide({
actors: { actors: {
loadCachedPlans: fromPromise<PaymentPlansResponse | null>( loadCachedPlans: fromPromise<
PaymentPlansResponse | null,
PaymentPlansActorInput
>(
async () => null, async () => null,
), ),
refreshPlans: fromPromise(async () => refreshPlans: fromPromise<
PaymentPlansResponse,
PaymentPlansActorInput
>(async () =>
PaymentPlansResponse.from({ PaymentPlansResponse.from({
isFirstRecharge: false, isFirstRecharge: false,
plans: [ plans: [
@@ -285,13 +392,19 @@ describe("paymentMachine", () => {
const actor = createActor( const actor = createActor(
paymentMachine.provide({ paymentMachine.provide({
actors: { actors: {
loadCachedPlans: fromPromise<PaymentPlansResponse | null>( loadCachedPlans: fromPromise<
PaymentPlansResponse | null,
PaymentPlansActorInput
>(
async () => async () =>
PaymentPlansResponse.from({ PaymentPlansResponse.from({
plans: [quarterlyPlan], plans: [quarterlyPlan],
}), }),
), ),
refreshPlans: fromPromise<PaymentPlansResponse>( refreshPlans: fromPromise<
PaymentPlansResponse,
PaymentPlansActorInput
>(
async () => refreshPlansPromise, async () => refreshPlansPromise,
), ),
}, },
+6 -1
View File
@@ -2,9 +2,14 @@
* Payment 状态机:事件联合 * Payment 状态机:事件联合
*/ */
import type { PayChannel } from "@/data/dto/payment"; import type { PayChannel } from "@/data/dto/payment";
import type { PaymentPlanCatalog } from "./payment-state";
export type PaymentEvent = export type PaymentEvent =
| { type: "PaymentInit"; payChannel?: PayChannel } | {
type: "PaymentInit";
payChannel?: PayChannel;
catalog?: PaymentPlanCatalog;
}
| { type: "PaymentPlanSelected"; planId: string } | { type: "PaymentPlanSelected"; planId: string }
| { type: "PaymentPayChannelChanged"; payChannel: PayChannel } | { type: "PaymentPayChannelChanged"; payChannel: PayChannel }
| { type: "PaymentAutoRenewChanged"; autoRenew: boolean } | { type: "PaymentAutoRenewChanged"; autoRenew: boolean }
+16 -4
View File
@@ -12,18 +12,30 @@ import {
import { getPaymentRepository } from "@/data/repositories/payment_repository"; import { getPaymentRepository } from "@/data/repositories/payment_repository";
import { Result } from "@/utils"; import { Result } from "@/utils";
import type { PaymentPlanCatalog } from "./payment-state";
export const loadCachedPaymentPlansActor = export const loadCachedPaymentPlansActor =
fromPromise<PaymentPlansResponse | null>(async () => { fromPromise<
PaymentPlansResponse | null,
{ catalog: PaymentPlanCatalog }
>(async ({ input }) => {
if (input.catalog === "tip") return null;
const paymentRepo = getPaymentRepository(); const paymentRepo = getPaymentRepository();
const result = await paymentRepo.getCachedPlans(); const result = await paymentRepo.getCachedPlans();
if (Result.isErr(result)) throw result.error; if (Result.isErr(result)) throw result.error;
return result.data; return result.data;
}); });
export const refreshPaymentPlansActor = fromPromise<PaymentPlansResponse>( export const refreshPaymentPlansActor = fromPromise<
async () => { PaymentPlansResponse,
{ catalog: PaymentPlanCatalog }
>(
async ({ input }) => {
const paymentRepo = getPaymentRepository(); 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; if (Result.isErr(result)) throw result.error;
return result.data; return result.data;
}, },
@@ -24,6 +24,7 @@ export function defaultAutoRenewForPlan(
plans: readonly PaymentPlan[] = [], plans: readonly PaymentPlan[] = [],
): boolean { ): boolean {
const plan = plans.find((item) => item.planId === planId); const plan = plans.find((item) => item.planId === planId);
if (plan?.orderType === "tip") return false;
if (plan?.dolAmount !== null && plan?.dolAmount !== undefined) return false; if (plan?.dolAmount !== null && plan?.dolAmount !== undefined) return false;
return ( return (
!planId.includes("lifetime") && !planId.includes("lifetime") &&
+26 -4
View File
@@ -49,9 +49,10 @@ export const paymentMachine = setup({
on: { on: {
PaymentInit: { PaymentInit: {
target: "loadingCachedPlans", target: "loadingCachedPlans",
actions: assign(({ event }) => actions: assign(({ context, event }) => ({
event.payChannel ? { payChannel: event.payChannel } : {}, planCatalog: event.catalog ?? context.planCatalog,
), ...(event.payChannel ? { payChannel: event.payChannel } : {}),
})),
}, },
}, },
}, },
@@ -59,6 +60,7 @@ export const paymentMachine = setup({
loadingCachedPlans: { loadingCachedPlans: {
invoke: { invoke: {
src: "loadCachedPlans", src: "loadCachedPlans",
input: ({ context }) => ({ catalog: context.planCatalog }),
onDone: [ onDone: [
{ {
guard: ({ event }) => (event.output?.plans.length ?? 0) > 0, guard: ({ event }) => (event.output?.plans.length ?? 0) > 0,
@@ -84,6 +86,7 @@ export const paymentMachine = setup({
loadingPlans: { loadingPlans: {
invoke: { invoke: {
src: "refreshPlans", src: "refreshPlans",
input: ({ context }) => ({ catalog: context.planCatalog }),
onDone: { onDone: {
target: "ready", target: "ready",
actions: assign(({ context, event }) => { actions: assign(({ context, event }) => {
@@ -105,6 +108,7 @@ export const paymentMachine = setup({
refreshingPlans: { refreshingPlans: {
invoke: { invoke: {
src: "refreshPlans", src: "refreshPlans",
input: ({ context }) => ({ catalog: context.planCatalog }),
onDone: { onDone: {
target: "ready", target: "ready",
actions: assign(({ context, event }) => { actions: assign(({ context, event }) => {
@@ -125,7 +129,25 @@ export const paymentMachine = setup({
ready: { ready: {
on: { 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: { PaymentPlanSelected: {
actions: assign(({ context, event }) => ({ actions: assign(({ context, event }) => ({
...selectPlanState(event.planId, context.plans), ...selectPlanState(event.planId, context.plans),
+4
View File
@@ -7,7 +7,10 @@ import type {
PaymentPlan, PaymentPlan,
} from "@/data/dto/payment"; } from "@/data/dto/payment";
export type PaymentPlanCatalog = "default" | "tip";
export interface PaymentState { export interface PaymentState {
planCatalog: PaymentPlanCatalog;
plans: PaymentPlan[]; plans: PaymentPlan[];
isFirstRecharge: boolean; isFirstRecharge: boolean;
selectedPlanId: string; selectedPlanId: string;
@@ -23,6 +26,7 @@ export interface PaymentState {
} }
export const initialState: PaymentState = { export const initialState: PaymentState = {
planCatalog: "default",
plans: [], plans: [],
isFirstRecharge: false, isFirstRecharge: false,
selectedPlanId: "", selectedPlanId: "",