feat(analytics): add behavior and payment funnel tracking
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user