diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.test.ts b/apps/code/src/renderer/features/billing/stores/seatStore.test.ts index 72e344b73..464d4e97d 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.test.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.test.ts @@ -45,12 +45,17 @@ vi.mock("@renderer/trpc", () => ({ }, })); +vi.mock("@utils/analytics", () => ({ track: vi.fn() })); + import { trpcClient } from "@renderer/trpc"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { track } from "@utils/analytics"; import { useSeatStore } from "./seatStore"; const mockInvalidatePlanCache = vi.mocked( trpcClient.llmGateway.invalidatePlanCache.mutate, ); +const mockTrack = vi.mocked(track); function makeSeat(overrides: Partial = {}): SeatData { return { @@ -170,6 +175,10 @@ describe("seatStore", () => { expect(client.upgradeSeat).toHaveBeenCalledWith(PLAN_PRO); expect(useSeatStore.getState().seat).toEqual(proSeat); expect(mockInvalidatePlanCache).toHaveBeenCalled(); + expect(mockTrack).toHaveBeenCalledWith( + ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, + { plan_key: PLAN_PRO, previous_plan_key: PLAN_FREE }, + ); }); it("no-ops when already on pro", async () => { @@ -183,6 +192,7 @@ describe("seatStore", () => { expect(client.upgradeSeat).not.toHaveBeenCalled(); expect(client.createSeat).not.toHaveBeenCalled(); expect(useSeatStore.getState().seat).toEqual(proSeat); + expect(mockTrack).not.toHaveBeenCalled(); }); it("upgrades alpha pro seat to paid pro", async () => { @@ -197,6 +207,10 @@ describe("seatStore", () => { expect(client.upgradeSeat).toHaveBeenCalledWith(PLAN_PRO); expect(useSeatStore.getState().seat).toEqual(proSeat); + expect(mockTrack).toHaveBeenCalledWith( + ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, + { plan_key: PLAN_PRO, previous_plan_key: PLAN_PRO_ALPHA }, + ); }); it("creates pro seat when none exists", async () => { @@ -209,21 +223,66 @@ describe("seatStore", () => { expect(client.createSeat).toHaveBeenCalledWith(PLAN_PRO); expect(mockInvalidatePlanCache).toHaveBeenCalled(); + expect(mockTrack).toHaveBeenCalledWith( + ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, + { plan_key: PLAN_PRO }, + ); }); }); describe("cancelSeat", () => { it("cancels and re-fetches seat", async () => { - const canceledSeat = makeSeat({ status: "canceling" }); + const proSeat = makeSeat({ plan_key: PLAN_PRO }); + const cancelingSeat = makeSeat({ + plan_key: PLAN_PRO, + status: "canceling", + }); + useSeatStore.setState({ seat: proSeat }); const client = mockClient({ - getMySeat: vi.fn().mockResolvedValue(canceledSeat), + getMySeat: vi.fn().mockResolvedValue(cancelingSeat), }); await useSeatStore.getState().cancelSeat(); expect(client.cancelSeat).toHaveBeenCalled(); - expect(useSeatStore.getState().seat).toEqual(canceledSeat); + expect(useSeatStore.getState().seat).toEqual(cancelingSeat); expect(mockInvalidatePlanCache).toHaveBeenCalled(); + expect(mockTrack).toHaveBeenCalledWith( + ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED, + { plan_key: PLAN_PRO }, + ); + }); + + it("falls back to API response plan_key when store seat is null", async () => { + const cancelingSeat = makeSeat({ + plan_key: PLAN_PRO, + status: "canceling", + }); + const client = mockClient({ + getMySeat: vi.fn().mockResolvedValue(cancelingSeat), + }); + + await useSeatStore.getState().cancelSeat(); + + expect(client.cancelSeat).toHaveBeenCalled(); + expect(mockTrack).toHaveBeenCalledWith( + ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED, + { plan_key: PLAN_PRO }, + ); + }); + + it("skips tracking when no plan_key is available", async () => { + const client = mockClient({ + getMySeat: vi.fn().mockResolvedValue(null), + }); + + await useSeatStore.getState().cancelSeat(); + + expect(client.cancelSeat).toHaveBeenCalled(); + expect(mockTrack).not.toHaveBeenCalledWith( + ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED, + expect.anything(), + ); }); }); diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts index f5064416b..e61399f83 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -4,8 +4,10 @@ import { SeatSubscriptionRequiredError, } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import type { SeatData } from "@shared/types/seat"; import { PLAN_FREE, PLAN_PRO } from "@shared/types/seat"; +import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { queryClient } from "@utils/queryClient"; import { create } from "zustand"; @@ -186,6 +188,10 @@ export const useSeatStore = create()((set, get) => ({ isLoading: false, billingOrgId: seat.organization_id ?? null, }); + track(ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, { + plan_key: seat.plan_key, + previous_plan_key: existing.plan_key, + }); invalidatePlanCache(); return; } @@ -196,6 +202,9 @@ export const useSeatStore = create()((set, get) => ({ isLoading: false, billingOrgId: seat.organization_id ?? null, }); + track(ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, { + plan_key: seat.plan_key, + }); invalidatePlanCache(); } catch (error) { handleSeatError(error, set); @@ -206,6 +215,7 @@ export const useSeatStore = create()((set, get) => ({ set({ isLoading: true, error: null, redirectUrl: null }); try { const client = await getClient(); + const previousPlanKey = get().seat?.plan_key; await client.cancelSeat(); const seat = await client.getMySeat(); set({ @@ -214,6 +224,12 @@ export const useSeatStore = create()((set, get) => ({ isLoading: false, billingOrgId: seat?.organization_id ?? null, }); + const cancelledPlanKey = previousPlanKey ?? seat?.plan_key; + if (cancelledPlanKey) { + track(ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED, { + plan_key: cancelledPlanKey, + }); + } invalidatePlanCache(); } catch (error) { handleSeatError(error, set); diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 066452850..8679f18ad 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -331,6 +331,16 @@ export interface SetupSkippedProperties { entry_point: "during_scan" | "after_done"; } +// Subscription / billing events +export interface SubscriptionStartedProperties { + plan_key: string; + previous_plan_key?: string; +} + +export interface SubscriptionCancelledProperties { + plan_key: string; +} + // Event names as constants export const ANALYTICS_EVENTS = { // App lifecycle @@ -419,6 +429,10 @@ export const ANALYTICS_EVENTS = { // Prompt history events PROMPT_HISTORY_OPENED: "Prompt history opened", PROMPT_HISTORY_SELECTED: "Prompt history selected", + + // Subscription events + SUBSCRIPTION_STARTED: "Subscription started", + SUBSCRIPTION_CANCELLED: "Subscription cancelled", } as const; // Event property mapping @@ -502,4 +516,8 @@ export type EventPropertyMap = { // Prompt history events [ANALYTICS_EVENTS.PROMPT_HISTORY_OPENED]: PromptHistoryOpenedProperties; [ANALYTICS_EVENTS.PROMPT_HISTORY_SELECTED]: PromptHistorySelectedProperties; + + // Subscription events + [ANALYTICS_EVENTS.SUBSCRIPTION_STARTED]: SubscriptionStartedProperties; + [ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED]: SubscriptionCancelledProperties; };