From b2e2375e91071a1d72abdfe2d86672868a615d96 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 16 Jul 2026 09:17:32 -0400 Subject: [PATCH 1/2] chore(billing): delete the dead seat-based billing feature The usage-based billing cutover shipped (#3485, #3487, #3488) and the seat model is dead code: nothing rendered useSeat, the seat store was only fed by auth/onboarding sync calls, and the /api/seats/* client methods have no remaining product surface. Removed: shared seat types/plan keys, core SeatService/seatView/ seatErrors + tokens, the UI seat store/client/hook, api-client seat methods and seat error classes, the SUBSCRIPTION_STARTED/CANCELLED analytics events, the orphaned "upgrade_dialog" surface literal, and the gateway invalidate-plan-cache chain (router procedure, service method, endpoint, URL helper) whose only caller was the seat upgrade flow. Auth identity sync keeps the usage-snapshot cache clear (still load-bearing for usage billing); the llmGateway query invalidation it carried was a no-op (no queries live under that key) and is dropped. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- apps/code/src/main/di/container.ts | 3 - .../src/renderer/desktop-contributions.ts | 2 - packages/agent/src/posthog-api.ts | 14 +- packages/agent/src/utils/gateway.ts | 7 - packages/api-client/src/posthog-client.ts | 145 --------- packages/core/src/billing/billing.module.ts | 8 - packages/core/src/billing/identifiers.ts | 27 -- packages/core/src/billing/seatErrors.ts | 24 -- packages/core/src/billing/seatService.test.ts | 280 ------------------ packages/core/src/billing/seatService.ts | 174 ----------- packages/core/src/billing/seatView.test.ts | 55 ---- packages/core/src/billing/seatView.ts | 41 --- packages/core/src/llm-gateway/identifiers.ts | 1 - .../core/src/llm-gateway/llm-gateway.test.ts | 27 -- packages/core/src/llm-gateway/llm-gateway.ts | 20 -- .../src/routers/llm-gateway.router.ts | 6 - packages/shared/src/analytics-events.ts | 15 - packages/shared/src/index.ts | 10 - packages/shared/src/seat.ts | 36 --- .../ui/src/features/auth/useAuthSession.ts | 27 +- .../ui/src/features/billing/billing.module.ts | 3 - .../ui/src/features/billing/seatClient.ts | 68 ----- .../ui/src/features/billing/seatStore.test.ts | 134 --------- packages/ui/src/features/billing/seatStore.ts | 103 ------- packages/ui/src/features/billing/useSeat.ts | 23 -- .../components/ProjectSelectStep.tsx | 7 - 26 files changed, 10 insertions(+), 1250 deletions(-) delete mode 100644 packages/core/src/billing/billing.module.ts delete mode 100644 packages/core/src/billing/identifiers.ts delete mode 100644 packages/core/src/billing/seatErrors.ts delete mode 100644 packages/core/src/billing/seatService.test.ts delete mode 100644 packages/core/src/billing/seatService.ts delete mode 100644 packages/core/src/billing/seatView.test.ts delete mode 100644 packages/core/src/billing/seatView.ts delete mode 100644 packages/shared/src/seat.ts delete mode 100644 packages/ui/src/features/billing/seatClient.ts delete mode 100644 packages/ui/src/features/billing/seatStore.test.ts delete mode 100644 packages/ui/src/features/billing/seatStore.ts delete mode 100644 packages/ui/src/features/billing/useSeat.ts diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index bf8033965f..63878c6604 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -4,7 +4,6 @@ import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises"; import { TypedContainer } from "@inversifyjs/strongly-typed"; import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; import { - getGatewayInvalidatePlanCacheUrl, getGatewayUsageUrl, getLlmGatewayUrl, } from "@posthog/agent/posthog-api"; @@ -491,8 +490,6 @@ container.bind(LLM_GATEWAY_HOST).toDynamicValue((ctx) => { messagesUrl: (apiHost: string) => `${getLlmGatewayUrl(apiHost)}/v1/messages`, usageUrl: (apiHost: string) => getGatewayUsageUrl(apiHost), - invalidatePlanCacheUrl: (apiHost: string) => - getGatewayInvalidatePlanCacheUrl(apiHost), defaultModel: DEFAULT_GATEWAY_MODEL, }; }); diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts index e8bbaefcb8..c3565ea3eb 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -1,6 +1,5 @@ import { agentChatCoreModule } from "@posthog/core/agent-chat/agentChat.module"; import { autoresearchCoreModule } from "@posthog/core/autoresearch/autoresearch.module"; -import { billingCoreModule } from "@posthog/core/billing/billing.module"; import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module"; import { inboxCoreModule } from "@posthog/core/inbox/inbox.module"; import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module"; @@ -37,7 +36,6 @@ export function registerDesktopContributions(): void { authUiModule, autoresearchCoreModule, billingUiModule, - billingCoreModule, taskThreadCoreModule, browserTabsUiModule, cloneUiModule, diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index 5ad0d1c1e5..26cab42049 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -7,17 +7,9 @@ import type { TaskRun, TaskRunArtifact, } from "./types"; -import { - getGatewayInvalidatePlanCacheUrl, - getGatewayUsageUrl, - getLlmGatewayUrl, -} from "./utils/gateway"; - -export { - getGatewayInvalidatePlanCacheUrl, - getGatewayUsageUrl, - getLlmGatewayUrl, -}; +import { getGatewayUsageUrl, getLlmGatewayUrl } from "./utils/gateway"; + +export { getGatewayUsageUrl, getLlmGatewayUrl }; const DEFAULT_USER_AGENT = `posthog/agent.hog.dev; version: ${packageJson.version}`; diff --git a/packages/agent/src/utils/gateway.ts b/packages/agent/src/utils/gateway.ts index 47d8d250d8..b258086070 100644 --- a/packages/agent/src/utils/gateway.ts +++ b/packages/agent/src/utils/gateway.ts @@ -92,10 +92,3 @@ export function getGatewayUsageUrl( ): string { return `${getGatewayBaseUrl(posthogHost)}/v1/usage/${product}`; } - -export function getGatewayInvalidatePlanCacheUrl( - posthogHost: string, - product: GatewayProduct = "posthog_code", -): string { - return `${getGatewayBaseUrl(posthogHost)}/v1/usage/${product}/invalidate-plan-cache`; -} diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index f2967294ee..a9c5e72318 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -7,7 +7,6 @@ import type { CloudRunSource, ExecutionMode, PrAuthorshipMode, - SeatData, StoredLogEntry, TaskRunArtifactMetadata, } from "@posthog/shared"; @@ -15,7 +14,6 @@ import { DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, resolveCloudInitialPermissionMode, - SEAT_PRODUCT_KEY, } from "@posthog/shared"; import type { AgentAnalyticsData, @@ -119,22 +117,6 @@ export function setPosthogApiClientAppVersion(version: string): void { clientAppVersion = version; } -export class SeatSubscriptionRequiredError extends Error { - redirectUrl: string; - constructor(redirectUrl: string) { - super("Billing subscription required"); - this.name = "SeatSubscriptionRequiredError"; - this.redirectUrl = redirectUrl; - } -} - -export class SeatPaymentFailedError extends Error { - constructor(message?: string) { - super(message ?? "Payment failed"); - this.name = "SeatPaymentFailedError"; - } -} - export class SandboxCustomImagesDisabledError extends Error { constructor(message?: string) { super(message ?? "Custom sandbox images are not enabled"); @@ -4452,113 +4434,6 @@ export class PostHogAPIClient { return data.results ?? []; } - async getMySeat( - options: { best?: boolean } = { best: true }, - ): Promise { - try { - const url = new URL(`${this.api.baseUrl}/api/seats/me/`); - url.searchParams.set("product_key", SEAT_PRODUCT_KEY); - if (options.best) { - url.searchParams.set("best", "true"); - } - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: "/api/seats/me/", - }); - return (await response.json()) as SeatData; - } catch (error) { - if (this.isFetcherStatusError(error, 404)) { - return null; - } - throw error; - } - } - - async createSeat(planKey: string): Promise { - try { - const user = await this.getCurrentUser(); - const distinctId = user.distinct_id; - if (!distinctId) { - throw new Error("Cannot create seat: user has no distinct_id"); - } - const url = new URL(`${this.api.baseUrl}/api/seats/`); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: "/api/seats/", - overrides: { - body: JSON.stringify({ - product_key: SEAT_PRODUCT_KEY, - plan_key: planKey, - user_distinct_id: distinctId, - }), - }, - }); - return (await response.json()) as SeatData; - } catch (error) { - this.throwSeatError(error); - } - } - - async upgradeSeat(planKey: string): Promise { - try { - const url = new URL(`${this.api.baseUrl}/api/seats/me/`); - const response = await this.api.fetcher.fetch({ - method: "patch", - url, - path: "/api/seats/me/", - overrides: { - body: JSON.stringify({ - product_key: SEAT_PRODUCT_KEY, - plan_key: planKey, - }), - }, - }); - return (await response.json()) as SeatData; - } catch (error) { - this.throwSeatError(error); - } - } - - async cancelSeat(): Promise { - try { - const url = new URL(`${this.api.baseUrl}/api/seats/me/`); - url.searchParams.set("product_key", SEAT_PRODUCT_KEY); - await this.api.fetcher.fetch({ - method: "delete", - url, - path: "/api/seats/me/", - }); - } catch (error) { - if (this.isFetcherStatusError(error, 204)) { - return; - } - this.throwSeatError(error); - } - } - - async reactivateSeat(): Promise { - try { - const url = new URL(`${this.api.baseUrl}/api/seats/me/reactivate/`); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: "/api/seats/me/reactivate/", - overrides: { - body: JSON.stringify({ product_key: SEAT_PRODUCT_KEY }), - }, - }); - return (await response.json()) as SeatData; - } catch (error) { - this.throwSeatError(error); - } - } - - private isFetcherStatusError(error: unknown, status: number): boolean { - return error instanceof Error && error.message.includes(`[${status}]`); - } - private parseFetcherError(error: unknown): { status: number; body: Record; @@ -4607,26 +4482,6 @@ export class PostHogAPIClient { } } - private throwSeatError(error: unknown): never { - const parsed = this.parseFetcherError(error); - - if (parsed) { - if ( - parsed.status === 400 && - typeof parsed.body.redirect_url === "string" - ) { - throw new SeatSubscriptionRequiredError(parsed.body.redirect_url); - } - if (parsed.status === 402) { - const message = - typeof parsed.body.error === "string" ? parsed.body.error : undefined; - throw new SeatPaymentFailedError(message); - } - } - - throw error; - } - /** * Check if a feature flag is enabled for the current project. * Returns true if the flag exists and is active, false otherwise. diff --git a/packages/core/src/billing/billing.module.ts b/packages/core/src/billing/billing.module.ts deleted file mode 100644 index cb4283c994..0000000000 --- a/packages/core/src/billing/billing.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ContainerModule } from "inversify"; -import { SEAT_SERVICE } from "./identifiers"; -import { SeatService } from "./seatService"; - -export const billingCoreModule = new ContainerModule(({ bind }) => { - bind(SeatService).toSelf().inSingletonScope(); - bind(SEAT_SERVICE).toService(SeatService); -}); diff --git a/packages/core/src/billing/identifiers.ts b/packages/core/src/billing/identifiers.ts deleted file mode 100644 index b5c8bdc0b8..0000000000 --- a/packages/core/src/billing/identifiers.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SeatData } from "@posthog/shared"; - -export interface SubscriptionEventProps { - plan_key: string; - previous_plan_key?: string; -} - -export interface SeatClient { - getMySeat(options?: { best?: boolean }): Promise; - createSeat(planKey: string): Promise; - upgradeSeat(planKey: string): Promise; - cancelSeat(): Promise; - reactivateSeat(): Promise; - invalidatePlanCache(): void; - trackSubscriptionStarted(props: SubscriptionEventProps): void; - trackSubscriptionCancelled(props: SubscriptionEventProps): void; -} - -export interface SeatLogger { - info(message: string, ...args: unknown[]): void; - warn(message: string, ...args: unknown[]): void; - error(message: string, ...args: unknown[]): void; - debug(message: string, ...args: unknown[]): void; -} - -export const SEAT_CLIENT = Symbol.for("posthog.core.seatClient"); -export const SEAT_SERVICE = Symbol.for("posthog.core.seatService"); diff --git a/packages/core/src/billing/seatErrors.ts b/packages/core/src/billing/seatErrors.ts deleted file mode 100644 index ca7ddabe12..0000000000 --- a/packages/core/src/billing/seatErrors.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface ClassifiedSeatError { - error: string; - redirectUrl: string | null; -} - -export function classifySeatError(error: unknown): ClassifiedSeatError { - if (!(error instanceof Error)) { - return { error: "An unexpected error occurred", redirectUrl: null }; - } - - if (error.name === "SeatSubscriptionRequiredError") { - const redirectUrl = - "redirectUrl" in error && typeof error.redirectUrl === "string" - ? error.redirectUrl - : null; - return { error: "Billing subscription required", redirectUrl }; - } - - if (error.name === "SeatPaymentFailedError") { - return { error: error.message, redirectUrl: null }; - } - - return { error: error.message, redirectUrl: null }; -} diff --git a/packages/core/src/billing/seatService.test.ts b/packages/core/src/billing/seatService.test.ts deleted file mode 100644 index 9896257b0f..0000000000 --- a/packages/core/src/billing/seatService.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import type { RootLogger } from "@posthog/di/logger"; -import { - PLAN_FREE, - PLAN_PRO, - PLAN_PRO_ALPHA, - type SeatData, -} from "@posthog/shared"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { SeatClient } from "./identifiers"; -import { SeatService } from "./seatService"; - -function makeSeat(overrides: Partial = {}): SeatData { - return { - id: 1, - user_distinct_id: "user-123", - product_key: "posthog_code", - plan_key: PLAN_FREE, - status: "active", - end_reason: null, - created_at: 1_700_000_000_000, - active_until: null, - active_from: 1_700_000_000_000, - ...overrides, - }; -} - -function makeClient(overrides: Partial = {}): SeatClient { - return { - getMySeat: vi.fn().mockResolvedValue(null), - createSeat: vi.fn().mockResolvedValue(makeSeat()), - upgradeSeat: vi.fn().mockResolvedValue(makeSeat({ plan_key: PLAN_PRO })), - cancelSeat: vi.fn().mockResolvedValue(undefined), - reactivateSeat: vi.fn().mockResolvedValue(makeSeat()), - invalidatePlanCache: vi.fn(), - trackSubscriptionStarted: vi.fn(), - trackSubscriptionCancelled: vi.fn(), - ...overrides, - }; -} - -const logger: RootLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - scope: () => logger, -}; - -class SeatSubscriptionRequiredError extends Error { - redirectUrl: string; - constructor(redirectUrl: string) { - super("subscription required"); - this.name = "SeatSubscriptionRequiredError"; - this.redirectUrl = redirectUrl; - } -} - -class SeatPaymentFailedError extends Error { - constructor(message: string) { - super(message); - this.name = "SeatPaymentFailedError"; - } -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("fetchSeat", () => { - it("fetches existing seat", async () => { - const seat = makeSeat(); - const client = makeClient({ getMySeat: vi.fn().mockResolvedValue(seat) }); - const result = await new SeatService(client, logger).fetchSeat(); - expect(result.seat).toEqual(seat); - expect(result.error).toBeNull(); - }); - - it("auto-provisions free seat when none exists", async () => { - const seat = makeSeat(); - const client = makeClient({ - getMySeat: vi.fn().mockResolvedValue(null), - createSeat: vi.fn().mockResolvedValue(seat), - }); - const result = await new SeatService(client, logger).fetchSeat({ - autoProvision: true, - }); - expect(client.createSeat).toHaveBeenCalledWith(PLAN_FREE); - expect(result.seat).toEqual(seat); - }); - - it("does not auto-provision when option is false", async () => { - const client = makeClient(); - const result = await new SeatService(client, logger).fetchSeat(); - expect(client.createSeat).not.toHaveBeenCalled(); - expect(result.seat).toBeNull(); - }); - - it("keeps existing seat when fetch fails", async () => { - const existing = makeSeat(); - const client = makeClient({ - getMySeat: vi.fn().mockRejectedValue(new Error("Network error")), - }); - const result = await new SeatService(client, logger).fetchSeat({ - currentSeat: existing, - }); - expect(result.keepExisting).toBe(true); - expect(result.seat).toEqual(existing); - expect(result.error).toBeNull(); - }); -}); - -describe("provisionFreeSeat", () => { - it("creates free seat when none exists", async () => { - const seat = makeSeat(); - const client = makeClient({ createSeat: vi.fn().mockResolvedValue(seat) }); - const result = await new SeatService(client, logger).provisionFreeSeat(); - expect(client.createSeat).toHaveBeenCalledWith(PLAN_FREE); - expect(result.seat).toEqual(seat); - expect(result.orgSeatUnchanged).toBe(true); - expect(client.invalidatePlanCache).toHaveBeenCalled(); - }); - - it("uses existing seat instead of creating", async () => { - const existing = makeSeat(); - const client = makeClient({ - getMySeat: vi.fn().mockResolvedValue(existing), - }); - const result = await new SeatService(client, logger).provisionFreeSeat(); - expect(client.createSeat).not.toHaveBeenCalled(); - expect(result.seat).toEqual(existing); - expect(client.invalidatePlanCache).not.toHaveBeenCalled(); - }); -}); - -describe("upgradeToPro", () => { - it("upgrades existing free seat to pro", async () => { - const freeSeat = makeSeat({ plan_key: PLAN_FREE }); - const proSeat = makeSeat({ plan_key: PLAN_PRO }); - const client = makeClient({ - getMySeat: vi.fn().mockResolvedValue(freeSeat), - upgradeSeat: vi.fn().mockResolvedValue(proSeat), - }); - const result = await new SeatService(client, logger).upgradeToPro(); - expect(client.upgradeSeat).toHaveBeenCalledWith(PLAN_PRO); - expect(result.seat).toEqual(proSeat); - expect(client.invalidatePlanCache).toHaveBeenCalled(); - expect(client.trackSubscriptionStarted).toHaveBeenCalledWith({ - plan_key: PLAN_PRO, - previous_plan_key: PLAN_FREE, - }); - }); - - it("no-ops when already on pro", async () => { - const proSeat = makeSeat({ plan_key: PLAN_PRO }); - const client = makeClient({ - getMySeat: vi.fn().mockResolvedValue(proSeat), - }); - const result = await new SeatService(client, logger).upgradeToPro(); - expect(client.upgradeSeat).not.toHaveBeenCalled(); - expect(client.createSeat).not.toHaveBeenCalled(); - expect(result.seat).toEqual(proSeat); - expect(client.trackSubscriptionStarted).not.toHaveBeenCalled(); - }); - - it("upgrades alpha pro seat to paid pro", async () => { - const alphaSeat = makeSeat({ plan_key: PLAN_PRO_ALPHA }); - const proSeat = makeSeat({ plan_key: PLAN_PRO }); - const client = makeClient({ - getMySeat: vi.fn().mockResolvedValue(alphaSeat), - upgradeSeat: vi.fn().mockResolvedValue(proSeat), - }); - const result = await new SeatService(client, logger).upgradeToPro(); - expect(client.upgradeSeat).toHaveBeenCalledWith(PLAN_PRO); - expect(result.seat).toEqual(proSeat); - expect(client.trackSubscriptionStarted).toHaveBeenCalledWith({ - plan_key: PLAN_PRO, - previous_plan_key: PLAN_PRO_ALPHA, - }); - }); - - it("creates pro seat when none exists", async () => { - const proSeat = makeSeat({ plan_key: PLAN_PRO }); - const client = makeClient({ - createSeat: vi.fn().mockResolvedValue(proSeat), - }); - await new SeatService(client, logger).upgradeToPro(); - expect(client.createSeat).toHaveBeenCalledWith(PLAN_PRO); - expect(client.invalidatePlanCache).toHaveBeenCalled(); - expect(client.trackSubscriptionStarted).toHaveBeenCalledWith({ - plan_key: PLAN_PRO, - }); - }); - - it("does not invalidate plan cache on failure", async () => { - const client = makeClient({ - getMySeat: vi.fn().mockRejectedValue(new Error("Network error")), - }); - await new SeatService(client, logger).upgradeToPro(); - expect(client.invalidatePlanCache).not.toHaveBeenCalled(); - }); -}); - -describe("cancelSeat", () => { - it("cancels and re-fetches seat", async () => { - const cancelingSeat = makeSeat({ - plan_key: PLAN_PRO, - status: "canceling", - }); - const client = makeClient({ - getMySeat: vi.fn().mockResolvedValue(cancelingSeat), - }); - const result = await new SeatService(client, logger).cancelSeat(PLAN_PRO); - expect(client.cancelSeat).toHaveBeenCalled(); - expect(result.seat).toEqual(cancelingSeat); - expect(client.invalidatePlanCache).toHaveBeenCalled(); - expect(client.trackSubscriptionCancelled).toHaveBeenCalledWith({ - plan_key: PLAN_PRO, - }); - }); - - it("falls back to API response plan_key when previous is undefined", async () => { - const cancelingSeat = makeSeat({ - plan_key: PLAN_PRO, - status: "canceling", - }); - const client = makeClient({ - getMySeat: vi.fn().mockResolvedValue(cancelingSeat), - }); - await new SeatService(client, logger).cancelSeat(); - expect(client.trackSubscriptionCancelled).toHaveBeenCalledWith({ - plan_key: PLAN_PRO, - }); - }); - - it("skips tracking when no plan_key is available", async () => { - const client = makeClient({ - getMySeat: vi.fn().mockResolvedValue(null), - }); - await new SeatService(client, logger).cancelSeat(); - expect(client.cancelSeat).toHaveBeenCalled(); - expect(client.trackSubscriptionCancelled).not.toHaveBeenCalled(); - }); -}); - -describe("reactivateSeat", () => { - it("reactivates seat", async () => { - const seat = makeSeat({ status: "active" }); - const client = makeClient({ - reactivateSeat: vi.fn().mockResolvedValue(seat), - }); - const result = await new SeatService(client, logger).reactivateSeat(); - expect(result.seat).toEqual(seat); - expect(client.invalidatePlanCache).toHaveBeenCalled(); - }); -}); - -describe("error classification", () => { - it("sets redirect URL on subscription required error", async () => { - const client = makeClient({ - getMySeat: vi - .fn() - .mockRejectedValue( - new SeatSubscriptionRequiredError("/organization/billing"), - ), - }); - const result = await new SeatService(client, logger).fetchSeat(); - expect(result.error).toBe("Billing subscription required"); - expect(result.redirectUrl).toBe("/organization/billing"); - }); - - it("sets error on payment failure", async () => { - const client = makeClient({ - getMySeat: vi - .fn() - .mockRejectedValue(new SeatPaymentFailedError("Card declined")), - }); - const result = await new SeatService(client, logger).fetchSeat(); - expect(result.error).toBe("Card declined"); - }); -}); diff --git a/packages/core/src/billing/seatService.ts b/packages/core/src/billing/seatService.ts deleted file mode 100644 index 24f25cbc25..0000000000 --- a/packages/core/src/billing/seatService.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; -import { PLAN_FREE, PLAN_PRO, type SeatData } from "@posthog/shared"; -import { inject, injectable } from "inversify"; -import { SEAT_CLIENT, type SeatClient, type SeatLogger } from "./identifiers"; -import { type ClassifiedSeatError, classifySeatError } from "./seatErrors"; - -export interface SeatOperationResult { - seat: SeatData | null; - orgSeat: SeatData | null; - billingOrgId: string | null; - error: string | null; - redirectUrl: string | null; - keepExisting?: boolean; - orgSeatUnchanged?: boolean; -} - -function ok( - seat: SeatData | null, - orgSeat: SeatData | null, - orgSeatUnchanged = false, -): SeatOperationResult { - return { - seat, - orgSeat, - billingOrgId: seat?.organization_id ?? null, - error: null, - redirectUrl: null, - orgSeatUnchanged, - }; -} - -function fail(classified: ClassifiedSeatError): SeatOperationResult { - return { - seat: null, - orgSeat: null, - billingOrgId: null, - error: classified.error, - redirectUrl: classified.redirectUrl, - }; -} - -@injectable() -export class SeatService { - private readonly logger: SeatLogger; - - constructor( - @inject(SEAT_CLIENT) private readonly client: SeatClient, - @inject(ROOT_LOGGER) logger: RootLogger, - ) { - this.logger = logger.scope("seat-service"); - } - - private async fetchAndProvision(options: { - best: boolean; - autoProvision: boolean; - }): Promise { - let seat = await this.client.getMySeat({ best: options.best }); - if (!seat && options.autoProvision) { - this.logger.info("No seat found, auto-provisioning free plan", { - best: options.best, - }); - try { - seat = await this.client.createSeat(PLAN_FREE); - } catch { - this.logger.info("Auto-provision failed, re-fetching seat"); - seat = await this.client.getMySeat({ best: options.best }); - } - } - return seat; - } - - async fetchSeat(options?: { - autoProvision?: boolean; - currentSeat?: SeatData | null; - }): Promise { - try { - const autoProvision = options?.autoProvision ?? false; - const [seat, orgSeat] = await Promise.all([ - this.fetchAndProvision({ best: true, autoProvision }), - this.fetchAndProvision({ best: false, autoProvision }), - ]); - return ok(seat, orgSeat); - } catch (error) { - if (options?.currentSeat) { - this.logger.warn( - "fetchSeat failed but seat already loaded, keeping it", - error, - ); - return { - seat: options.currentSeat, - orgSeat: null, - billingOrgId: options.currentSeat.organization_id ?? null, - error: null, - redirectUrl: null, - keepExisting: true, - }; - } - return fail(classifySeatError(error)); - } - } - - async provisionFreeSeat(): Promise { - this.logger.info("Provisioning free seat"); - try { - const existing = await this.client.getMySeat(); - if (existing) { - this.logger.info("Seat already exists on server", { - plan: existing.plan_key, - status: existing.status, - }); - return ok(existing, null, true); - } - const seat = await this.client.createSeat(PLAN_FREE); - this.logger.info("Free seat created", { - id: seat.id, - plan: seat.plan_key, - }); - this.client.invalidatePlanCache(); - return ok(seat, null, true); - } catch (error) { - this.logger.error("provisionFreeSeat failed", error); - return fail(classifySeatError(error)); - } - } - - async upgradeToPro(): Promise { - try { - const existing = await this.client.getMySeat(); - if (existing) { - if (existing.plan_key === PLAN_PRO) { - return ok(existing, null, true); - } - const seat = await this.client.upgradeSeat(PLAN_PRO); - this.client.trackSubscriptionStarted({ - plan_key: seat.plan_key, - previous_plan_key: existing.plan_key, - }); - this.client.invalidatePlanCache(); - return ok(seat, seat); - } - const seat = await this.client.createSeat(PLAN_PRO); - this.client.trackSubscriptionStarted({ plan_key: seat.plan_key }); - this.client.invalidatePlanCache(); - return ok(seat, seat); - } catch (error) { - return fail(classifySeatError(error)); - } - } - - async cancelSeat(previousPlanKey?: string): Promise { - try { - await this.client.cancelSeat(); - const seat = await this.client.getMySeat(); - const cancelledPlanKey = previousPlanKey ?? seat?.plan_key; - if (cancelledPlanKey) { - this.client.trackSubscriptionCancelled({ plan_key: cancelledPlanKey }); - } - this.client.invalidatePlanCache(); - return ok(seat, seat); - } catch (error) { - return fail(classifySeatError(error)); - } - } - - async reactivateSeat(): Promise { - try { - const seat = await this.client.reactivateSeat(); - this.client.invalidatePlanCache(); - return ok(seat, seat); - } catch (error) { - return fail(classifySeatError(error)); - } - } -} diff --git a/packages/core/src/billing/seatView.test.ts b/packages/core/src/billing/seatView.test.ts deleted file mode 100644 index dfc36ada6a..0000000000 --- a/packages/core/src/billing/seatView.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { PLAN_FREE, PLAN_PRO, type SeatData } from "@posthog/shared"; -import { describe, expect, it } from "vitest"; -import { deriveSeatView } from "./seatView"; - -function makeSeat(overrides: Partial = {}): SeatData { - return { - id: 1, - user_distinct_id: "user-123", - product_key: "posthog_code", - plan_key: PLAN_FREE, - status: "active", - end_reason: null, - created_at: 1_700_000_000_000, - active_until: null, - active_from: 1_700_000_000_000, - ...overrides, - }; -} - -describe("deriveSeatView", () => { - it("returns defaults when no seat", () => { - const view = deriveSeatView(null, null); - expect(view.isPro).toBe(false); - expect(view.hasAccess).toBe(false); - expect(view.planLabel).toBe("Free"); - expect(view.activeUntil).toBeNull(); - expect(view.hasBetterPlanElsewhere).toBe(false); - }); - - it("labels a pro seat with access", () => { - const seat = makeSeat({ plan_key: PLAN_PRO }); - const view = deriveSeatView(seat, seat); - expect(view.isPro).toBe(true); - expect(view.isOrgPro).toBe(true); - expect(view.hasAccess).toBe(true); - expect(view.planLabel).toBe("Pro"); - }); - - it("flags a pro personal seat against a free org seat", () => { - const personal = makeSeat({ plan_key: PLAN_PRO }); - const org = makeSeat({ plan_key: PLAN_FREE }); - expect(deriveSeatView(personal, org).hasBetterPlanElsewhere).toBe(true); - }); - - it("detects canceling org seat and active_until", () => { - const org = makeSeat({ - plan_key: PLAN_PRO, - status: "canceling", - active_until: 1_800_000_000, - }); - const view = deriveSeatView(org, org); - expect(view.isCanceling).toBe(true); - expect(view.activeUntil).toEqual(new Date(1_800_000_000 * 1000)); - }); -}); diff --git a/packages/core/src/billing/seatView.ts b/packages/core/src/billing/seatView.ts deleted file mode 100644 index f85a79283e..0000000000 --- a/packages/core/src/billing/seatView.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { isProPlan, type SeatData, seatHasAccess } from "@posthog/shared"; - -export interface SeatView { - isPro: boolean; - isOrgPro: boolean; - hasAccess: boolean; - isCanceling: boolean; - planLabel: string; - activeUntil: Date | null; - hasBetterPlanElsewhere: boolean; -} - -export function deriveSeatView( - seat: SeatData | null, - orgSeat: SeatData | null, -): SeatView { - const isPro = isProPlan(seat?.plan_key); - const isOrgPro = isProPlan(orgSeat?.plan_key); - const hasAccess = seat ? seatHasAccess(seat.status) : false; - const isCanceling = orgSeat?.status === "canceling"; - const planLabel = isPro ? "Pro" : "Free"; - const activeUntil = orgSeat?.active_until - ? new Date(orgSeat.active_until * 1000) - : null; - - const hasBetterPlanElsewhere = - seat !== null && - orgSeat !== null && - isProPlan(seat.plan_key) && - !isProPlan(orgSeat.plan_key); - - return { - isPro, - isOrgPro, - hasAccess, - isCanceling, - planLabel, - activeUntil, - hasBetterPlanElsewhere, - }; -} diff --git a/packages/core/src/llm-gateway/identifiers.ts b/packages/core/src/llm-gateway/identifiers.ts index 3ef42646a5..5d671254e5 100644 --- a/packages/core/src/llm-gateway/identifiers.ts +++ b/packages/core/src/llm-gateway/identifiers.ts @@ -9,7 +9,6 @@ export interface LlmGatewayAuth { export interface LlmGatewayEndpoints { messagesUrl(apiHost: string): string; usageUrl(apiHost: string): string; - invalidatePlanCacheUrl(apiHost: string): string; defaultModel: string; } diff --git a/packages/core/src/llm-gateway/llm-gateway.test.ts b/packages/core/src/llm-gateway/llm-gateway.test.ts index b9306a3ed8..03dc0acac7 100644 --- a/packages/core/src/llm-gateway/llm-gateway.test.ts +++ b/packages/core/src/llm-gateway/llm-gateway.test.ts @@ -30,7 +30,6 @@ function createService( const endpoints: LlmGatewayEndpoints = { messagesUrl: (host) => `${host}/gateway/v1/messages`, usageUrl: (host) => `${host}/gateway/usage`, - invalidatePlanCacheUrl: (host) => `${host}/gateway/invalidate`, defaultModel: "claude-default", }; @@ -416,29 +415,3 @@ describe("LlmGatewayService.fetchUsage", () => { expect(promptBody.model).toBe("@cf/zai-org/glm-5.2"); }); }); - -describe("LlmGatewayService.invalidatePlanCache", () => { - it("POSTs to the invalidate URL and resolves on success", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 204 })); - const { service } = createService(fetchMock); - - await expect(service.invalidatePlanCache()).resolves.toBeUndefined(); - const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe(`${API_HOST}/gateway/invalidate`); - expect(init.method).toBe("POST"); - }); - - it("throws a plan_cache_error LlmGatewayError on non-ok response", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 500 })); - const { service } = createService(fetchMock); - - await expect(service.invalidatePlanCache()).rejects.toMatchObject({ - type: "plan_cache_error", - statusCode: 500, - }); - }); -}); diff --git a/packages/core/src/llm-gateway/llm-gateway.ts b/packages/core/src/llm-gateway/llm-gateway.ts index b3f011c6f2..9cc90c6585 100644 --- a/packages/core/src/llm-gateway/llm-gateway.ts +++ b/packages/core/src/llm-gateway/llm-gateway.ts @@ -284,24 +284,4 @@ export class LlmGatewayService { } return usage; } - - async invalidatePlanCache(): Promise { - const auth = await this.auth.getValidAccessToken(); - const url = this.endpoints.invalidatePlanCacheUrl(auth.apiHost); - - this.log.debug("Invalidating plan cache", { url }); - - const response = await this.auth.authenticatedFetch(url, { - method: "POST", - }); - - if (!response.ok) { - throw new LlmGatewayError( - `Failed to invalidate plan cache: HTTP ${response.status}`, - "plan_cache_error", - undefined, - response.status, - ); - } - } } diff --git a/packages/host-router/src/routers/llm-gateway.router.ts b/packages/host-router/src/routers/llm-gateway.router.ts index 006c9cc724..37955cc839 100644 --- a/packages/host-router/src/routers/llm-gateway.router.ts +++ b/packages/host-router/src/routers/llm-gateway.router.ts @@ -16,10 +16,4 @@ export const llmGatewayRouter = router({ model: input.model, }), ), - - invalidatePlanCache: publicProcedure.mutation(({ ctx }) => - ctx.container - .get(LLM_GATEWAY_SERVICE) - .invalidatePlanCache(), - ), }); diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 32756ce73f..efe590fb7f 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -972,7 +972,6 @@ export interface ChannelsSpaceViewedProperties { export type UpgradePromptShownSurface = | "usage_limit_modal" - | "upgrade_dialog" | "titlebar_card" | "billing_announcement" | "model_picker"; @@ -983,7 +982,6 @@ export type UpgradePromptClickedSurface = | "titlebar" | "titlebar_card" | "plan_page_card" - | "upgrade_dialog" | "billing_announcement" | "model_picker"; @@ -1009,15 +1007,6 @@ export interface UsageBillingAnnouncementAcknowledgedProperties { $set: { code_usage_billing_acknowledged_at: string }; } -export interface SubscriptionStartedProperties { - plan_key: string; - previous_plan_key?: string; -} - -export interface SubscriptionCancelledProperties { - plan_key: string; -} - // Claude Code session import events /** Where in the new-task suggestions the import was launched from. */ export type ClaudeSessionImportSource = "inline_card" | "picker_dialog"; @@ -1220,8 +1209,6 @@ export const ANALYTICS_EVENTS = { UPGRADE_PROMPT_SHOWN: "Upgrade prompt shown", UPGRADE_PROMPT_CLICKED: "Upgrade prompt clicked", CLOUD_TASK_USAGE_BLOCKED: "Cloud task usage blocked", - SUBSCRIPTION_STARTED: "Subscription started", - SUBSCRIPTION_CANCELLED: "Subscription cancelled", USAGE_BILLING_ANNOUNCEMENT_ACKNOWLEDGED: "Usage billing announcement acknowledged", @@ -1381,8 +1368,6 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED]: UpgradePromptClickedProperties; [ANALYTICS_EVENTS.USAGE_BILLING_ANNOUNCEMENT_ACKNOWLEDGED]: UsageBillingAnnouncementAcknowledgedProperties; [ANALYTICS_EVENTS.CLOUD_TASK_USAGE_BLOCKED]: CloudTaskUsageBlockedProperties; - [ANALYTICS_EVENTS.SUBSCRIPTION_STARTED]: SubscriptionStartedProperties; - [ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED]: SubscriptionCancelledProperties; // Project Bluebird (Channels) events [ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED]: ChannelsSpaceViewedProperties; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fe66950795..cffe7a2b5d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -191,16 +191,6 @@ export { type SagaStep, } from "./saga"; export { scoutSkillNameFromSlug, scoutSkillSlug } from "./scout-naming"; -export { - isProPlan, - PLAN_FREE, - PLAN_PRO, - PLAN_PRO_ALPHA, - SEAT_PRODUCT_KEY, - type SeatData, - type SeatStatus, - seatHasAccess, -} from "./seat"; export { type AcpMessage, IMPORTED_USER_PROMPT_META_KEY, diff --git a/packages/shared/src/seat.ts b/packages/shared/src/seat.ts deleted file mode 100644 index 5ff3d88a52..0000000000 --- a/packages/shared/src/seat.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type SeatStatus = - | "active" - | "canceling" - | "pending" - | "pending_payment" - | "expired" - | "withdrawn"; - -export interface SeatData { - id: number; - user_distinct_id: string; - product_key: string; - plan_key: string; - status: SeatStatus; - end_reason: string | null; - created_at: number; - active_until: number | null; - active_from: number; - organization_id?: string; - organization_name?: string; -} - -export const SEAT_PRODUCT_KEY = "posthog_code"; -export const PLAN_FREE = "posthog-code-free-20260301"; -export const PLAN_PRO = "posthog-code-pro-200-20260301"; -export const PLAN_PRO_ALPHA = "posthog-code-pro-0-20260422"; - -const PRO_PLANS = new Set([PLAN_PRO, PLAN_PRO_ALPHA]); - -export function isProPlan(planKey: string | undefined | null): boolean { - return planKey != null && PRO_PLANS.has(planKey); -} - -export function seatHasAccess(status: SeatStatus): boolean { - return status === "active" || status === "canceling"; -} diff --git a/packages/ui/src/features/auth/useAuthSession.ts b/packages/ui/src/features/auth/useAuthSession.ts index 44de89593a..ae6497b1ca 100644 --- a/packages/ui/src/features/auth/useAuthSession.ts +++ b/packages/ui/src/features/auth/useAuthSession.ts @@ -1,8 +1,5 @@ import { useHostTRPCClient } from "@posthog/host-router/react"; -import { BILLING_FLAG } from "@posthog/shared"; -import { useSeatStore } from "@posthog/ui/features/billing/seatStore"; import { USAGE_QUERY_KEY } from "@posthog/ui/features/billing/useUsage"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { identifyUser, resetUser, @@ -108,23 +105,15 @@ function useAuthAnalyticsIdentity( ]); } -function useSeatSync( - authIdentity: string | null, - billingEnabled: boolean, -): void { +function useUsageIdentitySync(authIdentity: string | null): void { const queryClient = useQueryClient(); + // biome-ignore lint/correctness/useExhaustiveDependencies: re-run on identity change useEffect(() => { - // Usage is identity-scoped billing data — drop the cached snapshot so a - // new sign-in never renders the previous account's spend. + // Usage is identity-scoped billing data — drop the cached snapshot on any + // sign-in, sign-out, or org/project switch so a new identity never renders + // the previous account's spend. queryClient.removeQueries({ queryKey: USAGE_QUERY_KEY }); - if (!authIdentity || !billingEnabled) { - useSeatStore.getState().reset(); - return; - } - - void useSeatStore.getState().fetchSeat({ autoProvision: true }); - void queryClient.invalidateQueries({ queryKey: [["llmGateway"]] }); - }, [authIdentity, billingEnabled, queryClient]); + }, [authIdentity, queryClient]); } export function useAuthSession() { @@ -133,12 +122,10 @@ export function useAuthSession() { const { data: currentUser } = useCurrentUser({ client }); const authIdentity = getAuthIdentity(authState); - const billingEnabled = useFeatureFlag(BILLING_FLAG); - useAuthSubscriptionSync(); useAuthIdentitySync(authState); useAuthAnalyticsIdentity(authIdentity, authState, currentUser); - useSeatSync(authIdentity, billingEnabled); + useUsageIdentitySync(authIdentity); return { authState, diff --git a/packages/ui/src/features/billing/billing.module.ts b/packages/ui/src/features/billing/billing.module.ts index 81152bf49d..b77ee58dbb 100644 --- a/packages/ui/src/features/billing/billing.module.ts +++ b/packages/ui/src/features/billing/billing.module.ts @@ -1,10 +1,7 @@ -import { SEAT_CLIENT } from "@posthog/core/billing/identifiers"; import { CONTRIBUTION } from "@posthog/di/contribution"; import { ContainerModule } from "inversify"; import { BillingContribution } from "./billing.contribution"; -import { UiSeatClient } from "./seatClient"; export const billingUiModule = new ContainerModule(({ bind }) => { bind(CONTRIBUTION).to(BillingContribution).inSingletonScope(); - bind(SEAT_CLIENT).to(UiSeatClient).inSingletonScope(); }); diff --git a/packages/ui/src/features/billing/seatClient.ts b/packages/ui/src/features/billing/seatClient.ts deleted file mode 100644 index dfa15da557..0000000000 --- a/packages/ui/src/features/billing/seatClient.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { - SeatClient, - SubscriptionEventProps, -} from "@posthog/core/billing/identifiers"; -import { - HOST_TRPC_CLIENT, - type HostTrpcClient, -} from "@posthog/host-router/client"; -import type { SeatData } from "@posthog/shared"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { inject, injectable } from "inversify"; -import { track } from "../../shell/analytics"; -import { - IMPERATIVE_QUERY_CLIENT, - type ImperativeQueryClient, -} from "../../shell/queryClient"; -import { getAuthenticatedClient } from "../auth/authClientImperative"; - -async function authedClient() { - const client = await getAuthenticatedClient(); - if (!client) { - throw new Error("Not authenticated"); - } - return client; -} - -@injectable() -export class UiSeatClient implements SeatClient { - constructor( - @inject(HOST_TRPC_CLIENT) - private readonly hostClient: HostTrpcClient, - @inject(IMPERATIVE_QUERY_CLIENT) - private readonly queryClient: ImperativeQueryClient, - ) {} - - async getMySeat(options?: { best?: boolean }): Promise { - return (await authedClient()).getMySeat(options); - } - - async createSeat(planKey: string): Promise { - return (await authedClient()).createSeat(planKey); - } - - async upgradeSeat(planKey: string): Promise { - return (await authedClient()).upgradeSeat(planKey); - } - - async cancelSeat(): Promise { - await (await authedClient()).cancelSeat(); - } - - async reactivateSeat(): Promise { - return (await authedClient()).reactivateSeat(); - } - - invalidatePlanCache(): void { - this.hostClient.llmGateway.invalidatePlanCache.mutate().catch(() => {}); - void this.queryClient.invalidateQueries({ queryKey: [["llmGateway"]] }); - } - - trackSubscriptionStarted(props: SubscriptionEventProps): void { - track(ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, props); - } - - trackSubscriptionCancelled(props: SubscriptionEventProps): void { - track(ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED, props); - } -} diff --git a/packages/ui/src/features/billing/seatStore.test.ts b/packages/ui/src/features/billing/seatStore.test.ts deleted file mode 100644 index 36608904b5..0000000000 --- a/packages/ui/src/features/billing/seatStore.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { SeatOperationResult } from "@posthog/core/billing/seatService"; -import { PLAN_PRO, type SeatData } from "@posthog/shared"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useSeatStore } from "./seatStore"; - -const serviceRef = vi.hoisted( - () => ({ current: null }) as { current: unknown }, -); - -vi.mock("@posthog/di/container", () => ({ - resolveService: () => serviceRef.current, -})); - -function makeSeat(overrides: Partial = {}): SeatData { - return { - id: 1, - user_distinct_id: "user-123", - product_key: "posthog_code", - plan_key: PLAN_PRO, - status: "active", - end_reason: null, - created_at: 1_700_000_000_000, - active_until: null, - active_from: 1_700_000_000_000, - organization_id: "org-1", - ...overrides, - }; -} - -function mockService(result: SeatOperationResult) { - const service = { - fetchSeat: vi.fn().mockResolvedValue(result), - provisionFreeSeat: vi.fn().mockResolvedValue(result), - upgradeToPro: vi.fn().mockResolvedValue(result), - cancelSeat: vi.fn().mockResolvedValue(result), - reactivateSeat: vi.fn().mockResolvedValue(result), - }; - serviceRef.current = service; - return service; -} - -function okResult(seat: SeatData): SeatOperationResult { - return { - seat, - orgSeat: seat, - billingOrgId: seat.organization_id ?? null, - error: null, - redirectUrl: null, - }; -} - -describe("seatStore (thin)", () => { - beforeEach(() => { - vi.clearAllMocks(); - useSeatStore.getState().reset(); - }); - - it("fetchSeat delegates to the service and applies the result", async () => { - const seat = makeSeat(); - const service = mockService(okResult(seat)); - - await useSeatStore.getState().fetchSeat({ autoProvision: true }); - - expect(service.fetchSeat).toHaveBeenCalledWith({ - autoProvision: true, - currentSeat: null, - }); - const state = useSeatStore.getState(); - expect(state.seat).toEqual(seat); - expect(state.billingOrgId).toBe("org-1"); - expect(state.isLoading).toBe(false); - }); - - it("applies a classified error from the service", async () => { - mockService({ - seat: null, - orgSeat: null, - billingOrgId: null, - error: "Billing subscription required", - redirectUrl: "/organization/billing", - }); - - await useSeatStore.getState().fetchSeat(); - - const state = useSeatStore.getState(); - expect(state.error).toBe("Billing subscription required"); - expect(state.redirectUrl).toBe("/organization/billing"); - }); - - it("keeps existing seat when service signals keepExisting", async () => { - const seat = makeSeat(); - useSeatStore.setState({ seat }); - mockService({ - seat, - orgSeat: null, - billingOrgId: "org-1", - error: null, - redirectUrl: null, - keepExisting: true, - }); - - await useSeatStore.getState().fetchSeat(); - - expect(useSeatStore.getState().seat).toEqual(seat); - expect(useSeatStore.getState().isLoading).toBe(false); - }); - - it("cancelSeat passes the current plan_key to the service", async () => { - const seat = makeSeat({ plan_key: PLAN_PRO }); - useSeatStore.setState({ seat }); - const service = mockService(okResult(seat)); - - await useSeatStore.getState().cancelSeat(); - - expect(service.cancelSeat).toHaveBeenCalledWith(PLAN_PRO); - }); - - it("reset clears all state", () => { - useSeatStore.setState({ - seat: makeSeat(), - isLoading: true, - error: "some error", - redirectUrl: "https://example.com", - }); - - useSeatStore.getState().reset(); - - const state = useSeatStore.getState(); - expect(state.seat).toBeNull(); - expect(state.isLoading).toBe(false); - expect(state.error).toBeNull(); - expect(state.redirectUrl).toBeNull(); - }); -}); diff --git a/packages/ui/src/features/billing/seatStore.ts b/packages/ui/src/features/billing/seatStore.ts deleted file mode 100644 index dde6133a84..0000000000 --- a/packages/ui/src/features/billing/seatStore.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { SEAT_SERVICE } from "@posthog/core/billing/identifiers"; -import type { - SeatOperationResult, - SeatService, -} from "@posthog/core/billing/seatService"; -import { resolveService } from "@posthog/di/container"; -import type { SeatData } from "@posthog/shared"; -import { create } from "zustand"; - -interface SeatStoreState { - seat: SeatData | null; - orgSeat: SeatData | null; - isLoading: boolean; - error: string | null; - redirectUrl: string | null; - billingOrgId: string | null; -} - -interface SeatStoreActions { - fetchSeat: (options?: { autoProvision?: boolean }) => Promise; - provisionFreeSeat: () => Promise; - upgradeToPro: () => Promise; - cancelSeat: () => Promise; - reactivateSeat: () => Promise; - clearError: () => void; - reset: () => void; -} - -type SeatStore = SeatStoreState & SeatStoreActions; - -const initialState: SeatStoreState = { - seat: null, - orgSeat: null, - isLoading: false, - error: null, - redirectUrl: null, - billingOrgId: null, -}; - -function applyResult( - set: (state: Partial) => void, - result: SeatOperationResult, -): void { - if (result.keepExisting) { - set({ isLoading: false }); - return; - } - set({ - seat: result.seat, - billingOrgId: result.billingOrgId, - error: result.error, - redirectUrl: result.redirectUrl, - isLoading: false, - ...(result.orgSeatUnchanged ? {} : { orgSeat: result.orgSeat }), - }); -} - -export const useSeatStore = create()((set, get) => ({ - ...initialState, - - fetchSeat: async (options?: { autoProvision?: boolean }) => { - set({ isLoading: true, error: null, redirectUrl: null }); - const service = resolveService(SEAT_SERVICE); - const result = await service.fetchSeat({ - autoProvision: options?.autoProvision, - currentSeat: get().seat, - }); - applyResult(set, result); - }, - - provisionFreeSeat: async () => { - set({ isLoading: true, error: null, redirectUrl: null }); - const result = - await resolveService(SEAT_SERVICE).provisionFreeSeat(); - applyResult(set, result); - }, - - upgradeToPro: async () => { - set({ isLoading: true, error: null, redirectUrl: null }); - const result = - await resolveService(SEAT_SERVICE).upgradeToPro(); - applyResult(set, result); - }, - - cancelSeat: async () => { - set({ isLoading: true, error: null, redirectUrl: null }); - const result = await resolveService(SEAT_SERVICE).cancelSeat( - get().seat?.plan_key, - ); - applyResult(set, result); - }, - - reactivateSeat: async () => { - set({ isLoading: true, error: null, redirectUrl: null }); - const result = - await resolveService(SEAT_SERVICE).reactivateSeat(); - applyResult(set, result); - }, - - clearError: () => set({ error: null, redirectUrl: null }), - - reset: () => set(initialState), -})); diff --git a/packages/ui/src/features/billing/useSeat.ts b/packages/ui/src/features/billing/useSeat.ts deleted file mode 100644 index 7067c88ea4..0000000000 --- a/packages/ui/src/features/billing/useSeat.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { deriveSeatView } from "@posthog/core/billing/seatView"; -import { useSeatStore } from "./seatStore"; - -export function useSeat() { - const seat = useSeatStore((s) => s.seat); - const orgSeat = useSeatStore((s) => s.orgSeat); - const isLoading = useSeatStore((s) => s.isLoading); - const error = useSeatStore((s) => s.error); - const redirectUrl = useSeatStore((s) => s.redirectUrl); - const billingOrgId = useSeatStore((s) => s.billingOrgId); - - const view = deriveSeatView(seat, orgSeat); - - return { - seat, - orgSeat, - isLoading, - error, - redirectUrl, - billingOrgId, - ...view, - }; -} diff --git a/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx b/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx index 4f3899aefa..7c0364f08e 100644 --- a/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx +++ b/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx @@ -13,7 +13,6 @@ import { ComboboxList, ComboboxTrigger, } from "@posthog/quill"; -import { BILLING_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { happyHog } from "@posthog/ui/assets/hedgehogs"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; @@ -27,8 +26,6 @@ import { useSwitchOrgMutation, } from "@posthog/ui/features/auth/useAuthMutations"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; -import { useSeatStore } from "@posthog/ui/features/billing/seatStore"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { StepActions } from "@posthog/ui/features/onboarding/components/StepActions"; import { type ProjectInfo, @@ -76,7 +73,6 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { const { data: fullUser, isLoading } = useCurrentUser({ client, }); - const billingEnabled = useFeatureFlag(BILLING_FLAG); const switchOrgTrpcMutation = useSwitchOrgMutation(); const organizations = useMemo(() => { @@ -110,9 +106,6 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { const switchOrgMutation = useMutation({ mutationFn: async (orgId: string) => { await switchOrgTrpcMutation.mutateAsync(orgId); - if (billingEnabled) { - void useSeatStore.getState().fetchSeat({ autoProvision: true }); - } }, onMutate: () => { setIsSwitchingOrg(true); From c0a330bed437c7fe5e6fa6633da504fbea4461b1 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 16 Jul 2026 14:33:17 -0400 Subject: [PATCH 2/2] fix(billing): re-clear usage cache on org switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI usage-cache clear keyed on authIdentity (region + project), but usage is org-scoped — the host UsageMonitorService keys its snapshot on currentOrgId. Switching between two orgs that both have no selected project leaves authIdentity at "region:none", so the effect never re-ran and a failed post-switch refresh could leave the previous org's spend cached. Depend on currentOrgId too, matching the host. Addresses the Greptile P1 review comment on #3507. Generated-By: PostHog Code Task-Id: 90f5b50d-504e-433d-82d9-2b9ead0bb1e5 --- .../src/features/auth/useAuthSession.test.tsx | 77 +++++++++++++++++++ .../ui/src/features/auth/useAuthSession.ts | 21 +++-- 2 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/features/auth/useAuthSession.test.tsx diff --git a/packages/ui/src/features/auth/useAuthSession.test.tsx b/packages/ui/src/features/auth/useAuthSession.test.tsx new file mode 100644 index 0000000000..e23e5efd0f --- /dev/null +++ b/packages/ui/src/features/auth/useAuthSession.test.tsx @@ -0,0 +1,77 @@ +import { USAGE_QUERY_KEY } from "@posthog/ui/features/billing/useUsage"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useUsageIdentitySync } from "./useAuthSession"; + +let queryClient: QueryClient; +function wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); +} + +describe("useUsageIdentitySync", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + }); + + it("drops the cached usage snapshot on mount", () => { + const removeSpy = vi.spyOn(queryClient, "removeQueries"); + + renderHook(() => useUsageIdentitySync("us:none", "org-a"), { wrapper }); + + expect(removeSpy).toHaveBeenCalledWith({ queryKey: USAGE_QUERY_KEY }); + expect(removeSpy).toHaveBeenCalledTimes(1); + }); + + it("re-clears the snapshot when the org changes even if authIdentity is unchanged", () => { + // Regression: usage is org-scoped, but authIdentity keys on region + project. + // Switching between two orgs that both have no selected project leaves + // authIdentity at "region:none", so keying only on it would leave the + // previous org's spend cached after a failed refresh. + const removeSpy = vi.spyOn(queryClient, "removeQueries"); + + const { rerender } = renderHook( + ({ authIdentity, orgId }) => useUsageIdentitySync(authIdentity, orgId), + { wrapper, initialProps: { authIdentity: "us:none", orgId: "org-a" } }, + ); + removeSpy.mockClear(); + + rerender({ authIdentity: "us:none", orgId: "org-b" }); + + expect(removeSpy).toHaveBeenCalledWith({ queryKey: USAGE_QUERY_KEY }); + expect(removeSpy).toHaveBeenCalledTimes(1); + }); + + it("re-clears the snapshot when authIdentity changes", () => { + const removeSpy = vi.spyOn(queryClient, "removeQueries"); + + const { rerender } = renderHook( + ({ authIdentity, orgId }) => useUsageIdentitySync(authIdentity, orgId), + { wrapper, initialProps: { authIdentity: "us:1", orgId: "org-a" } }, + ); + removeSpy.mockClear(); + + rerender({ authIdentity: "us:2", orgId: "org-a" }); + + expect(removeSpy).toHaveBeenCalledTimes(1); + }); + + it("does not re-clear when neither authIdentity nor org changes", () => { + const removeSpy = vi.spyOn(queryClient, "removeQueries"); + + const { rerender } = renderHook( + ({ authIdentity, orgId }) => useUsageIdentitySync(authIdentity, orgId), + { wrapper, initialProps: { authIdentity: "us:1", orgId: "org-a" } }, + ); + removeSpy.mockClear(); + + rerender({ authIdentity: "us:1", orgId: "org-a" }); + + expect(removeSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/auth/useAuthSession.ts b/packages/ui/src/features/auth/useAuthSession.ts index ae6497b1ca..9647571eae 100644 --- a/packages/ui/src/features/auth/useAuthSession.ts +++ b/packages/ui/src/features/auth/useAuthSession.ts @@ -105,15 +105,22 @@ function useAuthAnalyticsIdentity( ]); } -function useUsageIdentitySync(authIdentity: string | null): void { +export function useUsageIdentitySync( + authIdentity: string | null, + orgId: string | null, +): void { const queryClient = useQueryClient(); - // biome-ignore lint/correctness/useExhaustiveDependencies: re-run on identity change + // Usage is org-scoped billing data — drop the cached snapshot on any sign-in, + // sign-out, region, or org switch so a new org never renders the previous + // org's spend. authIdentity keys on region + project, which can stay constant + // across an org switch (e.g. between two orgs with no selected project), so + // depend on currentOrgId too — matching the host UsageMonitorService, which + // keys its snapshot on currentOrgId. Without it, a post-switch refresh that + // fails would leave the stale value cached. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-run on identity/org change useEffect(() => { - // Usage is identity-scoped billing data — drop the cached snapshot on any - // sign-in, sign-out, or org/project switch so a new identity never renders - // the previous account's spend. queryClient.removeQueries({ queryKey: USAGE_QUERY_KEY }); - }, [authIdentity, queryClient]); + }, [authIdentity, orgId, queryClient]); } export function useAuthSession() { @@ -125,7 +132,7 @@ export function useAuthSession() { useAuthSubscriptionSync(); useAuthIdentitySync(authState); useAuthAnalyticsIdentity(authIdentity, authState, currentUser); - useUsageIdentitySync(authIdentity); + useUsageIdentitySync(authIdentity, authState.currentOrgId); return { authState,