From 3a4f24cb062687c2320c9c1667af279738970c29 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 14 Jul 2026 11:48:46 +0800 Subject: [PATCH] feat(payment): implement coffee tip plans and update payment flow --- .agents/skills/sync-backend-api/SKILL.md | 10 -- docs/backend/FRONTEND_TIP_PAYMENT_API.md | 132 +++++++++++++++++ docs/external-entry.md | 8 +- e2e/fixtures/api-mocks.ts | 5 + e2e/fixtures/test-data.ts | 41 ++++++ .../tip/__tests__/tip-screen.helpers.test.ts | 15 +- src/app/tip/tip-screen.helpers.ts | 9 +- src/app/tip/tip-screen.tsx | 1 + .../payment/__tests__/payment_plan.test.ts | 38 ++++- src/data/dto/payment/index.ts | 1 + src/data/dto/payment/response/index.ts | 1 + .../response/tip_payment_plans_response.ts | 35 +++++ src/data/dto/payment/tip_payment_plan.ts | 30 ++++ .../__tests__/payment_repository.test.ts | 38 +++++ .../interfaces/ipayment_repository.ts | 3 + src/data/repositories/payment_repository.ts | 21 +++ src/data/schemas/payment/index.ts | 2 + src/data/schemas/payment/tip_payment_plan.ts | 11 ++ .../payment/tip_payment_plans_response.ts | 15 ++ .../api/__tests__/payment_api.test.ts | 51 +++++++ src/data/services/api/api_path.ts | 3 + src/data/services/api/payment_api.ts | 11 ++ src/lib/tip/__tests__/tip_coffee.test.ts | 6 +- src/lib/tip/tip_coffee.ts | 6 +- .../payment/__tests__/payment-machine.test.ts | 133 ++++++++++++++++-- src/stores/payment/payment-events.ts | 7 +- src/stores/payment/payment-machine.actors.ts | 20 ++- src/stores/payment/payment-machine.helpers.ts | 1 + src/stores/payment/payment-machine.ts | 30 +++- src/stores/payment/payment-state.ts | 4 + 30 files changed, 632 insertions(+), 56 deletions(-) create mode 100644 docs/backend/FRONTEND_TIP_PAYMENT_API.md create mode 100644 src/data/dto/payment/response/tip_payment_plans_response.ts create mode 100644 src/data/dto/payment/tip_payment_plan.ts create mode 100644 src/data/repositories/__tests__/payment_repository.test.ts create mode 100644 src/data/schemas/payment/tip_payment_plan.ts create mode 100644 src/data/schemas/payment/tip_payment_plans_response.ts create mode 100644 src/data/services/api/__tests__/payment_api.test.ts diff --git a/.agents/skills/sync-backend-api/SKILL.md b/.agents/skills/sync-backend-api/SKILL.md index 7a2d2bad..db43e288 100644 --- a/.agents/skills/sync-backend-api/SKILL.md +++ b/.agents/skills/sync-backend-api/SKILL.md @@ -36,16 +36,6 @@ description: Sync this Next.js frontend with backend API documentation | Mock | `src/data/mock//**` | 模拟请求 / 响应数据 | | 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` | - ## 后端文档来源 优先读取用户指定的文档或文件片段。 diff --git a/docs/backend/FRONTEND_TIP_PAYMENT_API.md b/docs/backend/FRONTEND_TIP_PAYMENT_API.md new file mode 100644 index 00000000..9ae49a99 --- /dev/null +++ b/docs/backend/FRONTEND_TIP_PAYMENT_API.md @@ -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 ' \ + -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 +} +``` diff --git a/docs/external-entry.md b/docs/external-entry.md index 12294355..b82d9996 100644 --- a/docs/external-entry.md +++ b/docs/external-entry.md @@ -57,9 +57,9 @@ https:///external-entry?target=private-room | `coffee_type` | 展示名称 | 价格 | 后端套餐 `planId` | | --- | --- | --- | --- | -| `small` | Small Coffee | US$4.99 | `tip_coffee_small` | -| `medium` | Medium Coffee | US$9.99 | `tip_coffee_medium` | -| `large` | Large Coffee | US$19.99 | `tip_coffee_large` | +| `small` | Small Coffee | US$4.99 | `tip_coffee_usd_4_99` | +| `medium` | Medium Coffee | US$9.99 | `tip_coffee_usd_9_99` | +| `large` | Large Coffee | US$19.99 | `tip_coffee_usd_19_99` | 分别进入三种咖啡打赏页面: @@ -71,7 +71,7 @@ https:///external-entry?target=tip&coffee_type=large 外部入口会将它们规范化为 `/tip?coffee_type=`。`coffee_type` 会在登录、Stripe 回跳和 Ezpay 回跳期间保留,确保支付流程始终使用最初选择的咖啡档位。 -实际创建订单时,前端只使用对应 `planId` 的后端套餐;Small 额外兼容旧套餐 `tip_coffee`。最终支付金额以后端返回的套餐数据为准,若找不到对应套餐,页面会禁止下单,避免使用错误档位。 +实际创建订单时,前端通过 `/api/payment/tip-plans` 获取套餐,并只使用接口返回的对应 `planId`。最终支付金额以后端返回的套餐数据为准,若找不到对应套餐,页面会禁止下单,避免使用错误档位。 ## 促销入口 diff --git a/e2e/fixtures/api-mocks.ts b/e2e/fixtures/api-mocks.ts index bc4d942e..bad002b9 100644 --- a/e2e/fixtures/api-mocks.ts +++ b/e2e/fixtures/api-mocks.ts @@ -18,6 +18,7 @@ import { paidVoiceHistoryResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, + tipPaymentPlansResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse, userEntitlementsResponse, @@ -189,6 +190,10 @@ export async function mockCoreApis( 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 route.fulfill({ json: apiEnvelope(createPaymentOrderResponse) }); }); diff --git a/e2e/fixtures/test-data.ts b/e2e/fixtures/test-data.ts index a9add0fa..183cfc27 100644 --- a/e2e/fixtures/test-data.ts +++ b/e2e/fixtures/test-data.ts @@ -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 createPaymentOrderResponse = { diff --git a/src/app/tip/__tests__/tip-screen.helpers.test.ts b/src/app/tip/__tests__/tip-screen.helpers.test.ts index 14fbd2ad..32b691bf 100644 --- a/src/app/tip/__tests__/tip-screen.helpers.test.ts +++ b/src/app/tip/__tests__/tip-screen.helpers.test.ts @@ -29,24 +29,23 @@ function makePlan(input: Partial): 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, }); diff --git a/src/app/tip/tip-screen.helpers.ts b/src/app/tip/tip-screen.helpers.ts index b070bf75..f0dd2ed5 100644 --- a/src/app/tip/tip-screen.helpers.ts +++ b/src/app/tip/tip-screen.helpers.ts @@ -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( diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index 50a07ce1..6276f198 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -80,6 +80,7 @@ export function TipScreen({ initialPayChannelAppliedRef.current = true; paymentDispatch({ type: "PaymentInit", + catalog: "tip", payChannel: resolvedInitialPayChannel, }); return; diff --git a/src/data/dto/payment/__tests__/payment_plan.test.ts b/src/data/dto/payment/__tests__/payment_plan.test.ts index ec94c9fb..9e34a774 100644 --- a/src/data/dto/payment/__tests__/payment_plan.test.ts +++ b/src/data/dto/payment/__tests__/payment_plan.test.ts @@ -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", + }, + ], + }); + }); +}); diff --git a/src/data/dto/payment/index.ts b/src/data/dto/payment/index.ts index 3c9ec8c0..cdfe6219 100644 --- a/src/data/dto/payment/index.ts +++ b/src/data/dto/payment/index.ts @@ -3,5 +3,6 @@ */ export * from "./payment_plan"; +export * from "./tip_payment_plan"; export * from "./request"; export * from "./response"; diff --git a/src/data/dto/payment/response/index.ts b/src/data/dto/payment/response/index.ts index 8354d4b6..5dc8d242 100644 --- a/src/data/dto/payment/response/index.ts +++ b/src/data/dto/payment/response/index.ts @@ -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"; diff --git a/src/data/dto/payment/response/tip_payment_plans_response.ts b/src/data/dto/payment/response/tip_payment_plans_response.ts new file mode 100644 index 00000000..d10cdd42 --- /dev/null +++ b/src/data/dto/payment/response/tip_payment_plans_response.ts @@ -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()), + }; + } +} diff --git a/src/data/dto/payment/tip_payment_plan.ts b/src/data/dto/payment/tip_payment_plan.ts new file mode 100644 index 00000000..53814b83 --- /dev/null +++ b/src/data/dto/payment/tip_payment_plan.ts @@ -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); + } +} diff --git a/src/data/repositories/__tests__/payment_repository.test.ts b/src/data/repositories/__tests__/payment_repository.test.ts new file mode 100644 index 00000000..02b097b5 --- /dev/null +++ b/src/data/repositories/__tests__/payment_repository.test.ts @@ -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, + }); + }); +}); diff --git a/src/data/repositories/interfaces/ipayment_repository.ts b/src/data/repositories/interfaces/ipayment_repository.ts index 9e6eb6a0..291b9f77 100644 --- a/src/data/repositories/interfaces/ipayment_repository.ts +++ b/src/data/repositories/interfaces/ipayment_repository.ts @@ -18,6 +18,9 @@ export interface IPaymentRepository { /** 获取本地缓存套餐列表。 */ getCachedPlans(): Promise>; + /** 获取咖啡打赏套餐列表。 */ + getTipPlans(): Promise>; + /** 清除本地缓存套餐列表。 */ clearCachedPlans(): Promise>; diff --git a/src/data/repositories/payment_repository.ts b/src/data/repositories/payment_repository.ts index 89d7e531..1da0e188 100644 --- a/src/data/repositories/payment_repository.ts +++ b/src/data/repositories/payment_repository.ts @@ -39,6 +39,27 @@ export class PaymentRepository implements IPaymentRepository { }); } + /** 获取咖啡打赏套餐列表,不写入通用套餐缓存。 */ + async getTipPlans(): Promise> { + 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> { return Result.wrap(async () => { diff --git a/src/data/schemas/payment/index.ts b/src/data/schemas/payment/index.ts index 705b23ed..7558398c 100644 --- a/src/data/schemas/payment/index.ts +++ b/src/data/schemas/payment/index.ts @@ -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"; diff --git a/src/data/schemas/payment/tip_payment_plan.ts b/src/data/schemas/payment/tip_payment_plan.ts new file mode 100644 index 00000000..7f3a7bc8 --- /dev/null +++ b/src/data/schemas/payment/tip_payment_plan.ts @@ -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; +export type TipPaymentPlanData = z.output; diff --git a/src/data/schemas/payment/tip_payment_plans_response.ts b/src/data/schemas/payment/tip_payment_plans_response.ts new file mode 100644 index 00000000..366b2001 --- /dev/null +++ b/src/data/schemas/payment/tip_payment_plans_response.ts @@ -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 +>; diff --git a/src/data/services/api/__tests__/payment_api.test.ts b/src/data/services/api/__tests__/payment_api.test.ts new file mode 100644 index 00000000..6b4d4939 --- /dev/null +++ b/src/data/services/api/__tests__/payment_api.test.ts @@ -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", + }, + ], + }); + }); +}); diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index d78cb5e3..677a8886 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -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`; diff --git a/src/data/services/api/payment_api.ts b/src/data/services/api/payment_api.ts index cf713425..c7550f37 100644 --- a/src/data/services/api/payment_api.ts +++ b/src/data/services/api/payment_api.ts @@ -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 { + const env = await httpClient>( + ApiPath.paymentTipPlans, + ); + return TipPaymentPlansResponse.fromJson( + unwrap(env) as Record, + ); + } + /** * 创建支付订单 */ diff --git a/src/lib/tip/__tests__/tip_coffee.test.ts b/src/lib/tip/__tests__/tip_coffee.test.ts index c72b621f..6c9d00e4 100644 --- a/src/lib/tip/__tests__/tip_coffee.test.ts +++ b/src/lib/tip/__tests__/tip_coffee.test.ts @@ -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", }); }); diff --git a/src/lib/tip/tip_coffee.ts b/src/lib/tip/tip_coffee.ts index b13d0c8d..b4f0a0eb 100644 --- a/src/lib/tip/tip_coffee.ts +++ b/src/lib/tip/tip_coffee.ts @@ -17,19 +17,19 @@ const TIP_COFFEE_OPTIONS: Record = { 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", }, }; diff --git a/src/stores/payment/__tests__/payment-machine.test.ts b/src/stores/payment/__tests__/payment-machine.test.ts index f519e8e8..6581c40e 100644 --- a/src/stores/payment/__tests__/payment-machine.test.ts +++ b/src/stores/payment/__tests__/payment-machine.test.ts @@ -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( + 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(); + 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( + 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( + 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( + 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( + loadCachedPlans: fromPromise< + PaymentPlansResponse | null, + PaymentPlansActorInput + >( async () => PaymentPlansResponse.from({ plans: [quarterlyPlan], }), ), - refreshPlans: fromPromise( + refreshPlans: fromPromise< + PaymentPlansResponse, + PaymentPlansActorInput + >( async () => refreshPlansPromise, ), }, diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index 8d1df7f1..45d63d14 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -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 } diff --git a/src/stores/payment/payment-machine.actors.ts b/src/stores/payment/payment-machine.actors.ts index 471e8ebb..7a987ddb 100644 --- a/src/stores/payment/payment-machine.actors.ts +++ b/src/stores/payment/payment-machine.actors.ts @@ -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(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( - 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; }, diff --git a/src/stores/payment/payment-machine.helpers.ts b/src/stores/payment/payment-machine.helpers.ts index 7f435d88..e81c3116 100644 --- a/src/stores/payment/payment-machine.helpers.ts +++ b/src/stores/payment/payment-machine.helpers.ts @@ -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") && diff --git a/src/stores/payment/payment-machine.ts b/src/stores/payment/payment-machine.ts index 5993b52b..b88cd994 100644 --- a/src/stores/payment/payment-machine.ts +++ b/src/stores/payment/payment-machine.ts @@ -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), diff --git a/src/stores/payment/payment-state.ts b/src/stores/payment/payment-state.ts index 05d5afb8..81249220 100644 --- a/src/stores/payment/payment-state.ts +++ b/src/stores/payment/payment-state.ts @@ -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: "",