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
@@ -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",
},
],
});
});
});
+1
View File
@@ -3,5 +3,6 @@
*/
export * from "./payment_plan";
export * from "./tip_payment_plan";
export * from "./request";
export * from "./response";
+1
View File
@@ -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()),
};
}
}
+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>>;
/** 获取咖啡打赏套餐列表。 */
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 () => {
+2
View File
@@ -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",
},
],
});
});
});
+3
View File
@@ -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`;
+11
View File
@@ -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>,
);
}
/**
* 创建支付订单
*/