feat(payment): implement coffee tip plans and update payment flow
This commit is contained in:
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付订单
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user