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
@@ -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,
),
},
+6 -1
View File
@@ -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 }
+16 -4
View File
@@ -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") &&
+26 -4
View File
@@ -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),
+4
View File
@@ -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: "",