feat(analytics): add behavior and payment funnel tracking

This commit is contained in:
2026-07-14 16:54:13 +08:00
parent ca55723e48
commit 81d6489978
70 changed files with 1576 additions and 81 deletions
@@ -7,6 +7,7 @@ import {
PaymentOrderStatusResponse,
PaymentPlansResponse,
} from "@/data/dto/payment";
import { behaviorAnalytics } from "@/lib/analytics";
import { paymentMachine } from "@/stores/payment/payment-machine";
import {
MAX_ORDER_POLLING_MS,
@@ -94,6 +95,7 @@ const tipPlan = {
function createTestPaymentMachine(
overrides: Partial<{
createOrderSpy: CreateOrderSpy;
createOrderError: Error;
orderStatus: "pending" | "paid" | "failed";
orderStatuses: Array<"pending" | "paid" | "failed">;
}> = {},
@@ -124,6 +126,7 @@ function createTestPaymentMachine(
CreateOrderInput
>(async ({ input }) => {
createOrderSpy(input);
if (overrides.createOrderError) throw overrides.createOrderError;
return CreatePaymentOrderResponse.from({
orderId: "pay_test_001",
payParams: {
@@ -152,6 +155,7 @@ function createTestPaymentMachine(
describe("paymentMachine", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("loads plans and selects the default monthly VIP plan", async () => {
@@ -460,6 +464,18 @@ describe("paymentMachine", () => {
});
it("creates an order, polls it, and reaches paid state", async () => {
const createOrderStart = vi
.spyOn(behaviorAnalytics, "createOrderStart")
.mockImplementation(() => undefined);
const createOrderSuccess = vi
.spyOn(behaviorAnalytics, "createOrderSuccess")
.mockImplementation(() => undefined);
const statusPollStart = vi
.spyOn(behaviorAnalytics, "statusPollStart")
.mockImplementation(() => undefined);
const statusPollUpdate = vi
.spyOn(behaviorAnalytics, "statusPollUpdate")
.mockImplementation(() => undefined);
const createOrderSpy = vi.fn<CreateOrderSpy>();
const actor = createActor(
createTestPaymentMachine({ createOrderSpy }),
@@ -481,6 +497,50 @@ describe("paymentMachine", () => {
expect(context.orderStatus).toBe("paid");
expect(context.orderPollingStartedAt).toBeNull();
expect(context.launchNonce).toBe(1);
expect(createOrderStart).toHaveBeenCalledWith(
expect.objectContaining({ planId: "vip_monthly" }),
"stripe",
);
expect(createOrderSuccess).toHaveBeenCalledWith(
expect.objectContaining({ planId: "vip_monthly" }),
"pay_test_001",
"stripe",
);
expect(statusPollStart).toHaveBeenCalledWith(
"pay_test_001",
expect.objectContaining({ planId: "vip_monthly" }),
);
expect(statusPollUpdate).toHaveBeenCalledWith(
"pay_test_001",
"paid",
expect.objectContaining({ planId: "vip_monthly" }),
);
actor.stop();
});
it("tracks order creation failures without interrupting failure handling", async () => {
const createOrderFailed = vi
.spyOn(behaviorAnalytics, "createOrderFailed")
.mockImplementation(() => undefined);
const actor = createActor(
createTestPaymentMachine({
createOrderError: new Error("backend unavailable"),
}),
).start();
actor.send({ type: "PaymentInit" });
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
actor.send({ type: "PaymentCreateOrderSubmitted" });
await waitFor(actor, (snapshot) => snapshot.matches("failed"));
expect(createOrderFailed).toHaveBeenCalledWith(
expect.objectContaining({ planId: "vip_monthly" }),
"stripe",
);
expect(actor.getSnapshot().context.errorMessage).toBe(
"backend unavailable",
);
actor.stop();
});
@@ -533,6 +593,9 @@ describe("paymentMachine", () => {
});
it("moves to failed state when a pending order exceeds polling timeout", async () => {
const statusPollTimeout = vi
.spyOn(behaviorAnalytics, "statusPollTimeout")
.mockImplementation(() => undefined);
const now = new Date("2026-06-30T00:00:00.000Z");
vi.useFakeTimers();
vi.setSystemTime(now);
@@ -555,6 +618,10 @@ describe("paymentMachine", () => {
expect(context.orderStatus).toBe("failed");
expect(context.orderPollingStartedAt).toBeNull();
expect(context.errorMessage).toBe(PAYMENT_TIMEOUT_ERROR_MESSAGE);
expect(statusPollTimeout).toHaveBeenCalledWith(
"pay_test_001",
expect.objectContaining({ planId: "vip_monthly" }),
);
actor.stop();
});