Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions apps/code/src/renderer/features/billing/stores/seatStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): SeatData {
return {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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(),
);
});
});
Comment thread
charlesvien marked this conversation as resolved.

Expand Down
16 changes: 16 additions & 0 deletions apps/code/src/renderer/features/billing/stores/seatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -186,6 +188,10 @@ export const useSeatStore = create<SeatStore>()((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;
}
Expand All @@ -196,6 +202,9 @@ export const useSeatStore = create<SeatStore>()((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);
Expand All @@ -206,6 +215,7 @@ export const useSeatStore = create<SeatStore>()((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({
Expand All @@ -214,6 +224,12 @@ export const useSeatStore = create<SeatStore>()((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);
Expand Down
18 changes: 18 additions & 0 deletions apps/code/src/shared/types/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
};
Loading