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();
});
@@ -179,6 +179,7 @@ export function selectPlanState(
export function resetOrderState(): Partial<PaymentState> {
return {
currentOrderId: null,
currentOrderPlanId: null,
payParams: null,
orderStatus: null,
orderPollingStartedAt: null,
+120 -37
View File
@@ -3,6 +3,7 @@
*/
import { assign, setup } from "xstate";
import { behaviorAnalytics } from "@/lib/analytics";
import type { PaymentEvent } from "./payment-events";
import { initialState, type PaymentState } from "./payment-state";
import {
@@ -28,6 +29,19 @@ export { initialState } from "./payment-state";
const POLL_DELAY_MS = 4000;
function getSelectedPlan(context: PaymentState) {
return context.plans.find(
(plan) => plan.planId === context.selectedPlanId,
);
}
function getCurrentOrderPlan(context: PaymentState) {
if (!context.currentOrderPlanId) return undefined;
return context.plans.find(
(plan) => plan.planId === context.currentOrderPlanId,
);
}
export const paymentMachine = setup({
types: {
context: {} as PaymentState,
@@ -192,6 +206,10 @@ export const paymentMachine = setup({
},
creatingOrder: {
entry: ({ context }) => {
const plan = getSelectedPlan(context);
if (plan) behaviorAnalytics.createOrderStart(plan, context.payChannel);
},
invoke: {
src: "createOrder",
input: ({ context }) => ({
@@ -201,20 +219,45 @@ export const paymentMachine = setup({
}),
onDone: {
target: "pollingOrder",
actions: assign(({ context, event }) => ({
currentOrderId: event.output.orderId,
payParams: event.output.payParams,
orderStatus: "pending",
orderPollingStartedAt: Date.now(),
errorMessage: null,
launchNonce: context.launchNonce + 1,
})),
actions: [
({ context, event }) => {
const plan = getSelectedPlan(context);
if (plan) {
behaviorAnalytics.createOrderSuccess(
plan,
event.output.orderId,
context.payChannel,
);
}
behaviorAnalytics.statusPollStart(
event.output.orderId,
plan,
);
},
assign(({ context, event }) => ({
currentOrderId: event.output.orderId,
currentOrderPlanId: context.selectedPlanId,
payParams: event.output.payParams,
orderStatus: "pending",
orderPollingStartedAt: Date.now(),
errorMessage: null,
launchNonce: context.launchNonce + 1,
})),
],
},
onError: {
target: "failed",
actions: assign(({ event }) => ({
errorMessage: toPaymentErrorMessage(event.error),
})),
actions: [
({ context }) => {
const plan = getSelectedPlan(context);
if (plan) {
behaviorAnalytics.createOrderFailed(plan, context.payChannel);
}
},
assign(({ event }) => ({
errorMessage: toPaymentErrorMessage(event.error),
})),
],
},
},
},
@@ -229,37 +272,73 @@ export const paymentMachine = setup({
{
guard: ({ event }) => event.output.status === "paid",
target: "paid",
actions: assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: null,
})),
actions: [
({ context, event }) =>
behaviorAnalytics.statusPollUpdate(
context.currentOrderId ?? "",
event.output.status,
getCurrentOrderPlan(context),
),
assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: null,
})),
],
},
{
guard: ({ event }) => event.output.status === "failed",
target: "failed",
actions: assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: "Payment failed or was cancelled.",
})),
actions: [
({ context, event }) =>
behaviorAnalytics.statusPollUpdate(
context.currentOrderId ?? "",
event.output.status,
getCurrentOrderPlan(context),
),
assign(({ event }) => ({
orderStatus: event.output.status,
orderPollingStartedAt: null,
errorMessage: "Payment failed or was cancelled.",
})),
],
},
{
guard: ({ context }) =>
hasOrderPollingTimedOut(context.orderPollingStartedAt),
target: "failed",
actions: assign({
orderStatus: "failed",
orderPollingStartedAt: null,
errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE,
}),
actions: [
({ context, event }) => {
const orderId = context.currentOrderId ?? "";
const plan = getCurrentOrderPlan(context);
behaviorAnalytics.statusPollUpdate(
orderId,
event.output.status,
plan,
);
behaviorAnalytics.statusPollTimeout(orderId, plan);
},
assign({
orderStatus: "failed",
orderPollingStartedAt: null,
errorMessage: PAYMENT_TIMEOUT_ERROR_MESSAGE,
}),
],
},
{
target: "waitingForPayment",
actions: assign(({ event }) => ({
orderStatus: event.output.status,
errorMessage: null,
})),
actions: [
({ context, event }) =>
behaviorAnalytics.statusPollUpdate(
context.currentOrderId ?? "",
event.output.status,
getCurrentOrderPlan(context),
),
assign(({ event }) => ({
orderStatus: event.output.status,
errorMessage: null,
})),
],
},
],
onError: {
@@ -354,13 +433,17 @@ export const paymentMachine = setup({
},
PaymentReturned: {
target: ".pollingOrder",
actions: assign(({ event }) => ({
currentOrderId: event.orderId,
payParams: null,
orderStatus: "pending",
orderPollingStartedAt: event.createdAt ?? Date.now(),
errorMessage: null,
})),
actions: [
({ event }) => behaviorAnalytics.statusPollStart(event.orderId),
assign(({ event }) => ({
currentOrderId: event.orderId,
currentOrderPlanId: null,
payParams: null,
orderStatus: "pending",
orderPollingStartedAt: event.createdAt ?? Date.now(),
errorMessage: null,
})),
],
},
PaymentLaunchFailed: {
target: ".failed",
+2
View File
@@ -18,6 +18,7 @@ export interface PaymentState {
autoRenew: boolean;
agreed: boolean;
currentOrderId: string | null;
currentOrderPlanId: string | null;
payParams: Record<string, unknown> | null;
orderStatus: PaymentOrderStatus | null;
orderPollingStartedAt: number | null;
@@ -34,6 +35,7 @@ export const initialState: PaymentState = {
autoRenew: true,
agreed: true,
currentOrderId: null,
currentOrderPlanId: null,
payParams: null,
orderStatus: null,
orderPollingStartedAt: null,