diff --git a/apps/code/src/main/db/repositories/auth-session-repository.ts b/apps/code/src/main/db/repositories/auth-session-repository.ts index 77abb45bd..2aa760039 100644 --- a/apps/code/src/main/db/repositories/auth-session-repository.ts +++ b/apps/code/src/main/db/repositories/auth-session-repository.ts @@ -1,4 +1,4 @@ -import type { CloudRegion } from "@shared/types/oauth"; +import type { CloudRegion } from "@shared/types/regions"; import { eq } from "drizzle-orm"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts index 973899889..9ae30f624 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -1,9 +1,7 @@ -import { - getCloudUrlFromRegion, - OAUTH_SCOPE_VERSION, -} from "@shared/constants/oauth"; -import type { CloudRegion } from "@shared/types/oauth"; +import { OAUTH_SCOPE_VERSION } from "@shared/constants/oauth"; +import type { CloudRegion } from "@shared/types/regions"; import { type BackoffOptions, sleepWithBackoff } from "@shared/utils/backoff"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { powerMonitor } from "electron"; import { inject, injectable, postConstruct, preDestroy } from "inversify"; import type { IAuthPreferenceRepository } from "../../db/repositories/auth-preference-repository"; diff --git a/apps/code/src/main/services/github-integration/service.ts b/apps/code/src/main/services/github-integration/service.ts index e923cbf53..2c3071049 100644 --- a/apps/code/src/main/services/github-integration/service.ts +++ b/apps/code/src/main/services/github-integration/service.ts @@ -1,4 +1,4 @@ -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { shell } from "electron"; import { injectable } from "inversify"; import { logger } from "../../utils/logger"; diff --git a/apps/code/src/main/services/linear-integration/service.ts b/apps/code/src/main/services/linear-integration/service.ts index 1d5434d43..c0404636e 100644 --- a/apps/code/src/main/services/linear-integration/service.ts +++ b/apps/code/src/main/services/linear-integration/service.ts @@ -1,4 +1,4 @@ -import { getCloudUrlFromRegion } from "@shared/constants/oauth.js"; +import { getCloudUrlFromRegion } from "@shared/utils/urls.js"; import { shell } from "electron"; import { injectable } from "inversify"; import { logger } from "../../utils/logger.js"; diff --git a/apps/code/src/main/services/llm-gateway/schemas.ts b/apps/code/src/main/services/llm-gateway/schemas.ts index 7b8c1ae3e..8268e9067 100644 --- a/apps/code/src/main/services/llm-gateway/schemas.ts +++ b/apps/code/src/main/services/llm-gateway/schemas.ts @@ -56,3 +56,20 @@ export interface AnthropicErrorResponse { code?: string; }; } + +export const usageBucketSchema = z.object({ + used_percent: z.number(), + resets_in_seconds: z.number(), + exceeded: z.boolean(), +}); + +export const usageOutput = z.object({ + product: z.string(), + user_id: z.number(), + sustained: usageBucketSchema, + burst: usageBucketSchema, + is_rate_limited: z.boolean(), +}); + +export type UsageBucket = z.infer; +export type UsageOutput = z.infer; diff --git a/apps/code/src/main/services/llm-gateway/service.ts b/apps/code/src/main/services/llm-gateway/service.ts index 0fc92bb94..00e04d766 100644 --- a/apps/code/src/main/services/llm-gateway/service.ts +++ b/apps/code/src/main/services/llm-gateway/service.ts @@ -1,15 +1,20 @@ -import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; +import { + getGatewayUsageUrl, + getLlmGatewayUrl, +} from "@posthog/agent/posthog-api"; import { net } from "electron"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; import type { AuthService } from "../auth/service"; -import type { - AnthropicErrorResponse, - AnthropicMessagesRequest, - AnthropicMessagesResponse, - LlmMessage, - PromptOutput, +import { + type AnthropicErrorResponse, + type AnthropicMessagesRequest, + type AnthropicMessagesResponse, + type LlmMessage, + type PromptOutput, + type UsageOutput, + usageOutput, } from "./schemas"; const log = logger.scope("llm-gateway"); @@ -134,4 +139,27 @@ export class LlmGatewayService { }, }; } + + async fetchUsage(): Promise { + const auth = await this.authService.getValidAccessToken(); + const usageUrl = getGatewayUsageUrl(auth.apiHost); + + log.debug("Fetching usage from gateway", { url: usageUrl }); + + const response = await this.authService.authenticatedFetch( + net.fetch, + usageUrl, + ); + + if (!response.ok) { + throw new LlmGatewayError( + `Failed to fetch usage: HTTP ${response.status}`, + "usage_error", + undefined, + response.status, + ); + } + + return usageOutput.parse(await response.json()); + } } diff --git a/apps/code/src/main/services/oauth/service.ts b/apps/code/src/main/services/oauth/service.ts index 501d5396b..094b57869 100644 --- a/apps/code/src/main/services/oauth/service.ts +++ b/apps/code/src/main/services/oauth/service.ts @@ -2,10 +2,10 @@ import * as crypto from "node:crypto"; import * as http from "node:http"; import type { Socket } from "node:net"; import { - getCloudUrlFromRegion, getOauthClientIdFromRegion, OAUTH_SCOPES, } from "@shared/constants/oauth"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { shell } from "electron"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; diff --git a/apps/code/src/main/trpc/routers/llm-gateway.ts b/apps/code/src/main/trpc/routers/llm-gateway.ts index 83c59ecac..a2dafcea7 100644 --- a/apps/code/src/main/trpc/routers/llm-gateway.ts +++ b/apps/code/src/main/trpc/routers/llm-gateway.ts @@ -1,6 +1,10 @@ import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; -import { promptInput, promptOutput } from "../../services/llm-gateway/schemas"; +import { + promptInput, + promptOutput, + usageOutput, +} from "../../services/llm-gateway/schemas"; import type { LlmGatewayService } from "../../services/llm-gateway/service"; import { publicProcedure, router } from "../trpc"; @@ -18,4 +22,8 @@ export const llmGatewayRouter = router({ model: input.model, }), ), + + usage: publicProcedure + .output(usageOutput) + .query(() => getService().fetchUsage()), }); diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index a28eb3bef..1a0c41b85 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1,5 +1,5 @@ import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; -import { type PermissionMode } from "@posthog/agent/execution-mode"; +import type { PermissionMode } from "@posthog/agent/execution-mode"; import type { ActionabilityJudgmentArtefact, AvailableSuggestedReviewer, @@ -24,11 +24,29 @@ import type { TaskRun, } from "@shared/types"; import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud"; +import type { SeatData } from "@shared/types/seat"; +import { SEAT_PRODUCT_KEY } from "@shared/types/seat"; import type { StoredLogEntry } from "@shared/types/session-events"; import { logger } from "@utils/logger"; import { buildApiFetcher } from "./fetcher"; import { createApiClient, type Schemas } from "./generated"; +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"; + } +} + const log = logger.scope("posthog-client"); export type McpRecommendedServer = Schemas.RecommendedServer; @@ -1178,39 +1196,6 @@ export class PostHogAPIClient { return await response.json(); } - /** - * Get billing information for a specific organization. - */ - async getOrgBilling(orgId: string): Promise<{ - has_active_subscription: boolean; - customer_id: string | null; - }> { - const url = new URL( - `${this.api.baseUrl}/api/organizations/${orgId}/billing/`, - ); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/organizations/${orgId}/billing/`, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch organization billing: ${response.statusText}`, - ); - } - - const data = await response.json(); - return { - has_active_subscription: - typeof data.has_active_subscription === "boolean" - ? data.has_active_subscription - : false, - customer_id: - typeof data.customer_id === "string" ? data.customer_id : null, - }; - } - async getSignalReports( params?: SignalReportsQueryParams, ): Promise { @@ -1741,6 +1726,145 @@ export class PostHogAPIClient { } } + async getMySeat(): Promise { + try { + const url = new URL(`${this.api.baseUrl}/api/seats/me/`); + url.searchParams.set("product_key", SEAT_PRODUCT_KEY); + 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; + } | null { + if (!(error instanceof Error)) return null; + const match = error.message.match(/\[(\d+)\]\s*(.*)/); + if (!match) return null; + try { + return { + status: Number.parseInt(match[1], 10), + body: JSON.parse(match[2]) as Record, + }; + } catch { + return { status: Number.parseInt(match[1], 10), body: {} }; + } + } + + 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/apps/code/src/renderer/features/auth/components/AuthScreen.tsx b/apps/code/src/renderer/features/auth/components/AuthScreen.tsx index 3ed465303..c7288e387 100644 --- a/apps/code/src/renderer/features/auth/components/AuthScreen.tsx +++ b/apps/code/src/renderer/features/auth/components/AuthScreen.tsx @@ -9,8 +9,8 @@ import { Callout, Flex, Spinner, Text, Theme } from "@radix-ui/themes"; import codeLogo from "@renderer/assets/images/code.svg"; import logomark from "@renderer/assets/images/logomark.svg"; import { trpcClient } from "@renderer/trpc/client"; -import { REGION_LABELS } from "@shared/constants/oauth"; -import type { CloudRegion } from "@shared/types/oauth"; +import type { CloudRegion } from "@shared/types/regions"; +import { REGION_LABELS } from "@shared/types/regions"; import { RegionSelect } from "./RegionSelect"; export const getErrorMessage = (error: unknown) => { diff --git a/apps/code/src/renderer/features/auth/components/RegionSelect.tsx b/apps/code/src/renderer/features/auth/components/RegionSelect.tsx index 4d8f3fc5e..ff3b4b6bb 100644 --- a/apps/code/src/renderer/features/auth/components/RegionSelect.tsx +++ b/apps/code/src/renderer/features/auth/components/RegionSelect.tsx @@ -1,6 +1,6 @@ import { Flex, Select, Text } from "@radix-ui/themes"; import { IS_DEV } from "@shared/constants/environment"; -import type { CloudRegion } from "@shared/types/oauth"; +import type { CloudRegion } from "@shared/types/regions"; import { useState } from "react"; interface RegionSelectProps { diff --git a/apps/code/src/renderer/features/auth/hooks/authClient.ts b/apps/code/src/renderer/features/auth/hooks/authClient.ts index 474245746..2f88f54b2 100644 --- a/apps/code/src/renderer/features/auth/hooks/authClient.ts +++ b/apps/code/src/renderer/features/auth/hooks/authClient.ts @@ -1,6 +1,6 @@ import { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc/client"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { useMemo } from "react"; import { type AuthState, diff --git a/apps/code/src/renderer/features/auth/hooks/authMutations.ts b/apps/code/src/renderer/features/auth/hooks/authMutations.ts index f9705b340..6fcf03703 100644 --- a/apps/code/src/renderer/features/auth/hooks/authMutations.ts +++ b/apps/code/src/renderer/features/auth/hooks/authMutations.ts @@ -8,7 +8,7 @@ import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore" import { resetSessionService } from "@features/sessions/service/service"; import { trpcClient } from "@renderer/trpc/client"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import type { CloudRegion } from "@shared/types/oauth"; +import type { CloudRegion } from "@shared/types/regions"; import { useNavigationStore } from "@stores/navigationStore"; import { useMutation } from "@tanstack/react-query"; import { track } from "@utils/analytics"; diff --git a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts index 93ae5e1ad..56673fcbd 100644 --- a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts +++ b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts @@ -8,6 +8,8 @@ import { useCurrentUser, } from "@features/auth/hooks/authQueries"; import { useAuthUiStateStore } from "@features/auth/stores/authUiStateStore"; +import { useSeatStore } from "@features/billing/stores/seatStore"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { trpcClient } from "@renderer/trpc/client"; import { identifyUser, resetUser } from "@utils/analytics"; import { logger } from "@utils/logger"; @@ -80,15 +82,34 @@ function useAuthAnalyticsIdentity( }, [authIdentity, authState.cloudRegion, authState.projectId, currentUser]); } +function useSeatSync( + authIdentity: string | null, + billingEnabled: boolean, +): void { + useEffect(() => { + if (!authIdentity || !billingEnabled) { + useSeatStore.getState().reset(); + return; + } + + void useSeatStore.getState().fetchSeat({ + autoProvision: true, + }); + }, [authIdentity, billingEnabled]); +} + export function useAuthSession() { const authState = useAuthStateValue((state) => state); const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); const authIdentity = getAuthIdentity(authState); + const billingEnabled = useFeatureFlag("posthog-code-billing"); + useAuthSubscriptionSync(); useAuthIdentitySync(authIdentity, authState.cloudRegion); useAuthAnalyticsIdentity(authIdentity, authState, currentUser); + useSeatSync(authIdentity, billingEnabled); return { authState, diff --git a/apps/code/src/renderer/features/auth/stores/authStore.test.ts b/apps/code/src/renderer/features/auth/stores/authStore.test.ts index cd5ce4e05..9f311853f 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.test.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockGetState = vi.hoisted(() => ({ query: vi.fn() })); -const mockOnStateChangedSubscribe = vi.hoisted(() => vi.fn()); const mockGetValidAccessToken = vi.hoisted(() => ({ query: vi.fn() })); const mockRefreshAccessToken = vi.hoisted(() => ({ mutate: vi.fn() })); const mockLogin = vi.hoisted(() => ({ mutate: vi.fn() })); @@ -15,7 +14,6 @@ vi.mock("@renderer/trpc/client", () => ({ trpcClient: { auth: { getState: mockGetState, - onStateChanged: { subscribe: mockOnStateChangedSubscribe }, getValidAccessToken: mockGetValidAccessToken, refreshAccessToken: mockRefreshAccessToken, login: mockLogin, @@ -38,6 +36,20 @@ vi.mock("@renderer/api/posthogClient", () => ({ this.getCurrentUser = mockGetCurrentUser; this.setTeamId = vi.fn(); }), + SeatSubscriptionRequiredError: class SeatSubscriptionRequiredError extends Error { + redirectUrl: string; + constructor(redirectUrl: string) { + super("Billing subscription required"); + this.name = "SeatSubscriptionRequiredError"; + this.redirectUrl = redirectUrl; + } + }, + SeatPaymentFailedError: class SeatPaymentFailedError extends Error { + constructor(message?: string) { + super(message ?? "Payment failed"); + this.name = "SeatPaymentFailedError"; + } + }, })); vi.mock("@utils/analytics", () => ({ @@ -113,8 +125,6 @@ describe("authStore", () => { hasCodeAccess: null, needsScopeReauth: false, }); - mockOnStateChangedSubscribe.mockReturnValue({ unsubscribe: vi.fn() }); - useAuthStore.setState({ cloudRegion: null, staleCloudRegion: null, @@ -127,17 +137,14 @@ describe("authStore", () => { needsScopeReauth: false, hasCodeAccess: null, hasCompletedOnboarding: false, - selectedPlan: null, - selectedOrgId: null, }); }); - it("initializes from main auth state", async () => { + it("syncs from main auth state", async () => { mockGetState.query.mockResolvedValue(authenticatedState); - const result = await useAuthStore.getState().initializeOAuth(); + await useAuthStore.getState().checkCodeAccess(); - expect(result).toBe(true); expect(useAuthStore.getState().isAuthenticated).toBe(true); expect(useAuthStore.getState().projectId).toBe(1); }); @@ -156,7 +163,7 @@ describe("authStore", () => { it("deduplicates expensive renderer auth sync for repeated auth-state events", async () => { mockGetState.query.mockResolvedValue(authenticatedState); - await useAuthStore.getState().initializeOAuth(); + await useAuthStore.getState().checkCodeAccess(); await useAuthStore.getState().checkCodeAccess(); expect(mockGetCurrentUser).toHaveBeenCalledTimes(1); @@ -176,7 +183,7 @@ describe("authStore", () => { needsScopeReauth: false, }); - await useAuthStore.getState().initializeOAuth(); + await useAuthStore.getState().checkCodeAccess(); await useAuthStore.getState().checkCodeAccess(); expect(resetUser).toHaveBeenCalledTimes(1); diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 76f36966b..b74f022d4 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -1,8 +1,10 @@ +import { useSeatStore } from "@features/billing/stores/seatStore"; +import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc/client"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import type { CloudRegion } from "@shared/types/oauth"; +import type { CloudRegion } from "@shared/types/regions"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { useNavigationStore } from "@stores/navigationStore"; import { identifyUser, resetUser, track } from "@utils/analytics"; import { logger } from "@utils/logger"; @@ -11,8 +13,6 @@ import { create } from "zustand"; const log = logger.scope("auth-store"); -let initializePromise: Promise | null = null; -let authStateSubscription: { unsubscribe: () => void } | null = null; let sessionResetCallback: (() => void) | null = null; let inFlightAuthSync: Promise | null = null; let inFlightAuthSyncKey: string | null = null; @@ -23,8 +23,6 @@ export function setSessionResetCallback(callback: () => void) { } export function resetAuthStoreModuleStateForTest(): void { - initializePromise = null; - authStateSubscription = null; sessionResetCallback = null; inFlightAuthSync = null; inFlightAuthSyncKey = null; @@ -43,17 +41,11 @@ interface AuthStoreState { needsScopeReauth: boolean; hasCodeAccess: boolean | null; hasCompletedOnboarding: boolean; - selectedPlan: "free" | "pro" | null; - selectedOrgId: string | null; checkCodeAccess: () => Promise; redeemInviteCode: (code: string) => Promise; loginWithOAuth: (region: CloudRegion) => Promise; signupWithOAuth: (region: CloudRegion) => Promise; - initializeOAuth: () => Promise; selectProject: (projectId: number) => Promise; - completeOnboarding: () => void; - selectPlan: (plan: "free" | "pro") => void; - selectOrg: (orgId: string) => void; logout: () => Promise; } @@ -197,22 +189,7 @@ async function syncAuthState(): Promise { await inFlightAuthSync; } -function ensureAuthSubscription(): void { - if (authStateSubscription) { - return; - } - - authStateSubscription = trpcClient.auth.onStateChanged.subscribe(undefined, { - onData: () => { - void syncAuthState(); - }, - onError: (error) => { - log.error("Auth state subscription error", { error }); - }, - }); -} - -export const useAuthStore = create((set, get) => ({ +export const useAuthStore = create((set) => ({ cloudRegion: null, staleCloudRegion: null, @@ -226,8 +203,6 @@ export const useAuthStore = create((set, get) => ({ hasCodeAccess: null, hasCompletedOnboarding: false, - selectedPlan: null, - selectedOrgId: null, checkCodeAccess: async () => { await syncAuthState(); @@ -256,22 +231,6 @@ export const useAuthStore = create((set, get) => ({ }); }, - initializeOAuth: async () => { - if (initializePromise) { - return initializePromise; - } - - initializePromise = (async () => { - ensureAuthSubscription(); - await syncAuthState(); - return get().isAuthenticated || get().needsScopeReauth; - })().finally(() => { - initializePromise = null; - }); - - return initializePromise; - }, - selectProject: async (projectId: number) => { sessionResetCallback?.(); await trpcClient.auth.selectProject.mutate({ projectId }); @@ -279,21 +238,11 @@ export const useAuthStore = create((set, get) => ({ useNavigationStore.getState().navigateToTaskInput(); }, - completeOnboarding: () => { - set({ hasCompletedOnboarding: true }); - }, - - selectPlan: (plan: "free" | "pro") => { - set({ selectedPlan: plan }); - }, - - selectOrg: (orgId: string) => { - set({ selectedOrgId: orgId }); - }, - logout: async () => { track(ANALYTICS_EVENTS.USER_LOGGED_OUT); sessionResetCallback?.(); + useSeatStore.getState().reset(); + useSettingsDialogStore.getState().close(); clearAuthenticatedRendererState({ clearAllQueries: true }); await trpcClient.auth.logout.mutate(); useNavigationStore.getState().navigateToTaskInput(); @@ -310,8 +259,6 @@ export const useAuthStore = create((set, get) => ({ needsProjectSelection: false, needsScopeReauth: false, hasCodeAccess: null, - selectedPlan: null, - selectedOrgId: null, })); inFlightAuthSync = null; inFlightAuthSyncKey = null; diff --git a/apps/code/src/renderer/features/auth/stores/authUiStateStore.ts b/apps/code/src/renderer/features/auth/stores/authUiStateStore.ts index 5295ca2cb..f546befbe 100644 --- a/apps/code/src/renderer/features/auth/stores/authUiStateStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authUiStateStore.ts @@ -1,4 +1,4 @@ -import type { CloudRegion } from "@shared/types/oauth"; +import type { CloudRegion } from "@shared/types/regions"; import { create } from "zustand"; interface AuthUiStateStoreState { diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.test.ts b/apps/code/src/renderer/features/billing/stores/seatStore.test.ts new file mode 100644 index 000000000..974f93404 --- /dev/null +++ b/apps/code/src/renderer/features/billing/stores/seatStore.test.ts @@ -0,0 +1,337 @@ +import type { SeatData } from "@shared/types/seat"; +import { PLAN_FREE, PLAN_PRO } from "@shared/types/seat"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIsFeatureFlagEnabled = vi.hoisted(() => vi.fn()); +const mockGetAuthenticatedClient = vi.hoisted(() => vi.fn()); + +vi.mock("@utils/analytics", () => ({ + isFeatureFlagEnabled: mockIsFeatureFlagEnabled, +})); + +vi.mock("@features/auth/hooks/authClient", () => ({ + getAuthenticatedClient: mockGetAuthenticatedClient, +})); + +vi.mock("@renderer/api/posthogClient", () => ({ + SeatSubscriptionRequiredError: class SeatSubscriptionRequiredError extends Error { + redirectUrl: string; + constructor(redirectUrl: string) { + super("Billing subscription required"); + this.name = "SeatSubscriptionRequiredError"; + this.redirectUrl = redirectUrl; + } + }, + SeatPaymentFailedError: class SeatPaymentFailedError extends Error { + constructor(message?: string) { + super(message ?? "Payment failed"); + this.name = "SeatPaymentFailedError"; + } + }, +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +vi.mock("@utils/urls", () => ({ + getPostHogUrl: (path: string) => `https://posthog.com${path}`, +})); + +import { useSeatStore } from "./seatStore"; + +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: Date.now(), + active_until: null, + active_from: Date.now(), + ...overrides, + }; +} + +function mockClient(overrides: Record = {}) { + const client = { + 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()), + ...overrides, + }; + mockGetAuthenticatedClient.mockResolvedValue(client); + return client; +} + +describe("seatStore", () => { + beforeEach(() => { + vi.clearAllMocks(); + useSeatStore.setState({ + seat: null, + isLoading: false, + error: null, + redirectUrl: null, + }); + }); + + describe("billing flag gate", () => { + it("fetchSeat does not call API when billing is disabled", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(false); + const client = mockClient(); + + await useSeatStore.getState().fetchSeat({ autoProvision: true }); + + expect(client.getMySeat).not.toHaveBeenCalled(); + expect(client.createSeat).not.toHaveBeenCalled(); + expect(useSeatStore.getState().seat).toBeNull(); + expect(useSeatStore.getState().error).toBe("Billing is not enabled"); + }); + + it("provisionFreeSeat does not call API when billing is disabled", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(false); + const client = mockClient(); + + await useSeatStore.getState().provisionFreeSeat(); + + expect(client.getMySeat).not.toHaveBeenCalled(); + expect(client.createSeat).not.toHaveBeenCalled(); + }); + + it("upgradeToPro does not call API when billing is disabled", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(false); + const client = mockClient(); + + await useSeatStore.getState().upgradeToPro(); + + expect(client.getMySeat).not.toHaveBeenCalled(); + expect(client.upgradeSeat).not.toHaveBeenCalled(); + }); + + it("cancelSeat does not call API when billing is disabled", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(false); + const client = mockClient(); + + await useSeatStore.getState().cancelSeat(); + + expect(client.cancelSeat).not.toHaveBeenCalled(); + }); + + it("reactivateSeat does not call API when billing is disabled", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(false); + const client = mockClient(); + + await useSeatStore.getState().reactivateSeat(); + + expect(client.reactivateSeat).not.toHaveBeenCalled(); + }); + }); + + describe("fetchSeat", () => { + it("fetches existing seat", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const seat = makeSeat(); + mockClient({ getMySeat: vi.fn().mockResolvedValue(seat) }); + + await useSeatStore.getState().fetchSeat(); + + const state = useSeatStore.getState(); + expect(state.seat).toEqual(seat); + expect(state.isLoading).toBe(false); + }); + + it("auto-provisions free seat when none exists", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const seat = makeSeat(); + const client = mockClient({ + getMySeat: vi.fn().mockResolvedValue(null), + createSeat: vi.fn().mockResolvedValue(seat), + }); + + await useSeatStore.getState().fetchSeat({ autoProvision: true }); + + expect(client.createSeat).toHaveBeenCalledWith(PLAN_FREE); + expect(useSeatStore.getState().seat).toEqual(seat); + }); + + it("does not auto-provision when option is false", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const client = mockClient(); + + await useSeatStore.getState().fetchSeat(); + + expect(client.createSeat).not.toHaveBeenCalled(); + expect(useSeatStore.getState().seat).toBeNull(); + }); + }); + + describe("provisionFreeSeat", () => { + it("creates free seat when none exists", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const seat = makeSeat(); + const client = mockClient({ + createSeat: vi.fn().mockResolvedValue(seat), + }); + + await useSeatStore.getState().provisionFreeSeat(); + + expect(client.createSeat).toHaveBeenCalledWith(PLAN_FREE); + expect(useSeatStore.getState().seat).toEqual(seat); + }); + + it("uses existing seat instead of creating", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const existing = makeSeat(); + const client = mockClient({ + getMySeat: vi.fn().mockResolvedValue(existing), + }); + + await useSeatStore.getState().provisionFreeSeat(); + + expect(client.createSeat).not.toHaveBeenCalled(); + expect(useSeatStore.getState().seat).toEqual(existing); + }); + }); + + describe("upgradeToPro", () => { + it("upgrades existing free seat to pro", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const freeSeat = makeSeat({ plan_key: PLAN_FREE }); + const proSeat = makeSeat({ plan_key: PLAN_PRO }); + const client = mockClient({ + getMySeat: vi.fn().mockResolvedValue(freeSeat), + upgradeSeat: vi.fn().mockResolvedValue(proSeat), + }); + + await useSeatStore.getState().upgradeToPro(); + + expect(client.upgradeSeat).toHaveBeenCalledWith(PLAN_PRO); + expect(useSeatStore.getState().seat).toEqual(proSeat); + }); + + it("no-ops when already on pro", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const proSeat = makeSeat({ plan_key: PLAN_PRO }); + const client = mockClient({ + getMySeat: vi.fn().mockResolvedValue(proSeat), + }); + + await useSeatStore.getState().upgradeToPro(); + + expect(client.upgradeSeat).not.toHaveBeenCalled(); + expect(client.createSeat).not.toHaveBeenCalled(); + expect(useSeatStore.getState().seat).toEqual(proSeat); + }); + + it("creates pro seat when none exists", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const proSeat = makeSeat({ plan_key: PLAN_PRO }); + const client = mockClient({ + createSeat: vi.fn().mockResolvedValue(proSeat), + }); + + await useSeatStore.getState().upgradeToPro(); + + expect(client.createSeat).toHaveBeenCalledWith(PLAN_PRO); + }); + }); + + describe("cancelSeat", () => { + it("cancels and re-fetches seat", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const canceledSeat = makeSeat({ status: "canceling" }); + const client = mockClient({ + getMySeat: vi.fn().mockResolvedValue(canceledSeat), + }); + + await useSeatStore.getState().cancelSeat(); + + expect(client.cancelSeat).toHaveBeenCalled(); + expect(useSeatStore.getState().seat).toEqual(canceledSeat); + }); + }); + + describe("reactivateSeat", () => { + it("reactivates seat", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const seat = makeSeat({ status: "active" }); + mockClient({ + reactivateSeat: vi.fn().mockResolvedValue(seat), + }); + + await useSeatStore.getState().reactivateSeat(); + + expect(useSeatStore.getState().seat).toEqual(seat); + }); + }); + + describe("error handling", () => { + it("sets redirect URL on subscription required error", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const { SeatSubscriptionRequiredError } = await import( + "@renderer/api/posthogClient" + ); + mockClient({ + getMySeat: vi + .fn() + .mockRejectedValue( + new SeatSubscriptionRequiredError("/organization/billing"), + ), + }); + + await useSeatStore.getState().fetchSeat(); + + const state = useSeatStore.getState(); + expect(state.error).toBe("Billing subscription required"); + expect(state.redirectUrl).toBe( + "https://posthog.com/organization/billing", + ); + }); + + it("sets error on payment failure", async () => { + mockIsFeatureFlagEnabled.mockReturnValue(true); + const { SeatPaymentFailedError } = await import( + "@renderer/api/posthogClient" + ); + mockClient({ + getMySeat: vi + .fn() + .mockRejectedValue(new SeatPaymentFailedError("Card declined")), + }); + + await useSeatStore.getState().fetchSeat(); + + expect(useSeatStore.getState().error).toBe("Card declined"); + }); + }); + + describe("reset", () => { + it("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/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts new file mode 100644 index 000000000..17a38590b --- /dev/null +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -0,0 +1,178 @@ +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { + SeatPaymentFailedError, + SeatSubscriptionRequiredError, +} from "@renderer/api/posthogClient"; +import type { SeatData } from "@shared/types/seat"; +import { PLAN_FREE, PLAN_PRO } from "@shared/types/seat"; +import { isFeatureFlagEnabled } from "@utils/analytics"; +import { logger } from "@utils/logger"; +import { getPostHogUrl } from "@utils/urls"; +import { create } from "zustand"; + +const log = logger.scope("seat-store"); + +interface SeatStoreState { + seat: SeatData | null; + isLoading: boolean; + error: string | null; + redirectUrl: 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 BILLING_FLAG = "posthog-code-billing"; + +function assertBillingEnabled(): void { + if (!isFeatureFlagEnabled(BILLING_FLAG)) { + throw new Error("Billing is not enabled"); + } +} + +async function getClient() { + const client = await getAuthenticatedClient(); + if (!client) { + throw new Error("Not authenticated"); + } + return client; +} + +function handleSeatError( + error: unknown, + set: (state: Partial) => void, +): void { + if (!(error instanceof Error)) { + log.error("Seat operation failed", error); + set({ isLoading: false, error: "An unexpected error occurred" }); + return; + } + + if (error instanceof SeatSubscriptionRequiredError) { + set({ + isLoading: false, + error: "Billing subscription required", + redirectUrl: getPostHogUrl("/organization/billing"), + }); + return; + } + + if (error instanceof SeatPaymentFailedError) { + set({ isLoading: false, error: error.message }); + return; + } + + log.error("Seat operation failed", error); + set({ isLoading: false, error: error.message }); +} + +const initialState: SeatStoreState = { + seat: null, + isLoading: false, + error: null, + redirectUrl: null, +}; + +export const useSeatStore = create()((set) => ({ + ...initialState, + + fetchSeat: async (options?: { autoProvision?: boolean }) => { + set({ isLoading: true, error: null, redirectUrl: null }); + try { + assertBillingEnabled(); + const client = await getClient(); + let seat = await client.getMySeat(); + if (!seat && options?.autoProvision) { + log.info("No seat found, auto-provisioning free plan"); + seat = await client.createSeat(PLAN_FREE); + } + set({ seat, isLoading: false }); + } catch (error) { + handleSeatError(error, set); + } + }, + + provisionFreeSeat: async () => { + log.info("Provisioning free seat"); + set({ isLoading: true, error: null, redirectUrl: null }); + try { + assertBillingEnabled(); + const client = await getClient(); + const existing = await client.getMySeat(); + if (existing) { + log.info("Seat already exists on server", { + plan: existing.plan_key, + status: existing.status, + }); + set({ seat: existing, isLoading: false }); + return; + } + const seat = await client.createSeat(PLAN_FREE); + log.info("Free seat created", { id: seat.id, plan: seat.plan_key }); + set({ seat, isLoading: false }); + } catch (error) { + log.error("provisionFreeSeat failed", error); + handleSeatError(error, set); + } + }, + + upgradeToPro: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + try { + assertBillingEnabled(); + const client = await getClient(); + const existing = await client.getMySeat(); + if (existing) { + if (existing.plan_key === PLAN_PRO) { + set({ seat: existing, isLoading: false }); + return; + } + const seat = await client.upgradeSeat(PLAN_PRO); + set({ seat, isLoading: false }); + return; + } + const seat = await client.createSeat(PLAN_PRO); + set({ seat, isLoading: false }); + } catch (error) { + handleSeatError(error, set); + } + }, + + cancelSeat: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + try { + assertBillingEnabled(); + const client = await getClient(); + await client.cancelSeat(); + const seat = await client.getMySeat(); + set({ seat, isLoading: false }); + } catch (error) { + handleSeatError(error, set); + } + }, + + reactivateSeat: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + try { + assertBillingEnabled(); + const client = await getClient(); + const seat = await client.reactivateSeat(); + set({ seat, isLoading: false }); + } catch (error) { + handleSeatError(error, set); + } + }, + + clearError: () => set({ error: null, redirectUrl: null }), + + reset: () => set(initialState), +})); diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx index 69ef5861a..76433a59d 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -33,7 +33,6 @@ import { Text, Tooltip, } from "@radix-ui/themes"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import type { ActionabilityJudgmentArtefact, ActionabilityJudgmentContent, @@ -45,6 +44,7 @@ import type { SuggestedReviewer, SuggestedReviewersArtefact, } from "@shared/types"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { useNavigationStore } from "@stores/navigationStore"; import { type ReactNode, diff --git a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx index 698426284..8d7f6979c 100644 --- a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx @@ -7,8 +7,8 @@ import { InfoIcon, } from "@phosphor-icons/react"; import { trpcClient } from "@renderer/trpc/client"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; -import type { CloudRegion } from "@shared/types/oauth"; +import type { CloudRegion } from "@shared/types/regions"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { queryClient } from "@utils/queryClient"; import { useEffect, useRef } from "react"; diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts index ea38e740e..8ab735044 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts @@ -5,7 +5,7 @@ import type { Evaluation, SignalSourceConfig, } from "@renderer/api/posthogClient"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; diff --git a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx b/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx deleted file mode 100644 index d7ff782ac..000000000 --- a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { ArrowLeft, ArrowRight, Check } from "@phosphor-icons/react"; -import { Badge, Button, Flex, Text } from "@radix-ui/themes"; -import codeLogo from "@renderer/assets/images/code.svg"; -import { useEffect } from "react"; - -interface BillingStepProps { - onNext: () => void; - onBack: () => void; -} - -interface PlanFeature { - text: string; -} - -const FREE_FEATURES: PlanFeature[] = [ - { text: "Limited usage" }, - { text: "Local execution only" }, -]; - -const PRO_FEATURES: PlanFeature[] = [ - { text: "Unlimited usage*" }, - { text: "Local and cloud execution" }, -]; - -export function BillingStep({ onNext, onBack }: BillingStepProps) { - const selectedPlan = useOnboardingStore((state) => state.selectedPlan); - const selectPlan = useOnboardingStore((state) => state.selectPlan); - - useEffect(() => { - if (!selectedPlan) { - selectPlan("pro"); - } - }, [selectedPlan, selectPlan]); - - const handleContinue = () => { - onNext(); - }; - - return ( - - - PostHog - - - - - Choose your plan - - - {/* Free Plan */} - selectPlan("free")} - /> - - {/* Pro Plan */} - selectPlan("pro")} - recommended - /> - - - * Usage is limited to "human" level usage, this cannot be used as - your api key. If you hit this limit, please contact support. - - - - - - - - - - - ); -} - -interface PlanCardProps { - name: string; - price: string; - period: string; - features: PlanFeature[]; - isSelected: boolean; - onSelect: () => void; - recommended?: boolean; -} - -function PlanCard({ - name, - price, - period, - features, - isSelected, - onSelect, - recommended, -}: PlanCardProps) { - return ( - - - - - - {name} - - {recommended && ( - - Recommended - - )} - - - - {price} - - - {period} - - - - - - - - - {features.map((feature) => ( - - - - {feature.text} - - - ))} - - - ); -} diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx index 2cb8a7d10..aac5d8558 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx @@ -7,9 +7,8 @@ import { Button, Flex, Theme } from "@radix-ui/themes"; import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; import { useOnboardingFlow } from "../hooks/useOnboardingFlow"; -import { BillingStep } from "./BillingStep"; import { GitIntegrationStep } from "./GitIntegrationStep"; -import { OrgBillingStep } from "./OrgBillingStep"; +import { OrgStep } from "./OrgStep"; import { SignalsStep } from "./SignalsStep"; import { StepIndicator } from "./StepIndicator"; import { TutorialStep } from "./TutorialStep"; @@ -98,29 +97,16 @@ export function OnboardingFlow() { )} - {currentStep === "billing" && ( + {currentStep === "org" && ( - - - )} - - {currentStep === "org-billing" && ( - - + )} diff --git a/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx b/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx similarity index 50% rename from apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx rename to apps/code/src/renderer/features/onboarding/components/OrgStep.tsx index 7c600868a..6d581e483 100644 --- a/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx @@ -1,35 +1,50 @@ import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { authKeys, useCurrentUser } from "@features/auth/hooks/authQueries"; +import { + authKeys, + useAuthStateValue, + useCurrentUser, +} from "@features/auth/hooks/authQueries"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; +import { useProjects } from "@features/projects/hooks/useProjects"; import { useOrganizations } from "@hooks/useOrganizations"; import { ArrowLeft, ArrowRight, CheckCircle } from "@phosphor-icons/react"; import { - Badge, Box, Button, Callout, Flex, + Select, Skeleton, Text, } from "@radix-ui/themes"; import codeLogo from "@renderer/assets/images/code.svg"; +import { trpcClient } from "@renderer/trpc/client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { AnimatePresence, motion } from "framer-motion"; +import { useMemo } from "react"; -const log = logger.scope("org-billing-step"); +const log = logger.scope("org-step"); -interface OrgBillingStepProps { +interface OrgStepProps { onNext: () => void; onBack: () => void; } -export function OrgBillingStep({ onNext, onBack }: OrgBillingStepProps) { +export function OrgStep({ onNext, onBack }: OrgStepProps) { const selectedOrgId = useOnboardingStore((state) => state.selectedOrgId); const selectOrg = useOnboardingStore((state) => state.selectOrg); + const manuallySelectedProjectId = useOnboardingStore( + (state) => state.selectedProjectId, + ); + const setSelectedProjectId = useOnboardingStore( + (state) => state.selectProjectId, + ); const client = useAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); + const currentProjectId = useAuthStateValue((state) => state.projectId); const queryClient = useQueryClient(); + const switchOrganizationMutation = useMutation({ mutationFn: async (orgId: string) => { await client.switchOrganization(orgId); @@ -42,10 +57,17 @@ export function OrgBillingStep({ onNext, onBack }: OrgBillingStepProps) { }, }); - const { orgsWithBilling, effectiveSelectedOrgId, isLoading, error } = - useOrganizations(); + const { orgs, effectiveSelectedOrgId, isLoading, error } = useOrganizations(); const currentUserOrgId = currentUser?.organization?.id; + const hasOrgChanged = effectiveSelectedOrgId !== currentUserOrgId; + + const { projects, isLoading: projectsLoading } = useProjects(); + + const selectedProjectId = useMemo(() => { + if (manuallySelectedProjectId !== null) return manuallySelectedProjectId; + return currentProjectId ?? projects[0]?.id ?? null; + }, [manuallySelectedProjectId, currentProjectId, projects]); const handleContinue = async () => { if (!effectiveSelectedOrgId) return; @@ -54,17 +76,30 @@ export function OrgBillingStep({ onNext, onBack }: OrgBillingStepProps) { selectOrg(effectiveSelectedOrgId); } - if (client && effectiveSelectedOrgId !== currentUserOrgId) { + if (client && hasOrgChanged) { try { await switchOrganizationMutation.mutateAsync(effectiveSelectedOrgId); - } catch {} + } catch { + // Error handled by onError callback + } + } + + if ( + !hasOrgChanged && + selectedProjectId && + selectedProjectId !== currentProjectId + ) { + await trpcClient.auth.selectProject.mutate({ + projectId: selectedProjectId, + }); } onNext(); }; - const handleSelect = (orgId: string) => { + const handleSelectOrg = (orgId: string) => { selectOrg(orgId); + setSelectedProjectId(null); }; return ( @@ -79,7 +114,7 @@ export function OrgBillingStep({ onNext, onBack }: OrgBillingStepProps) { paddingBottom: 40, }} > - + PostHog - Select which organization should be billed for your PostHog Code - usage. + Select which PostHog organization and project to use with PostHog + Code. @@ -122,61 +157,101 @@ export function OrgBillingStep({ onNext, onBack }: OrgBillingStepProps) { marginBottom: "var(--space-6)", }} > - - {isLoading ? ( - - - - - - - + + Organization + + + {isLoading ? ( + + + + > + + + + + - - - ) : ( - + ) : ( + + + {orgs.map((org) => ( + handleSelectOrg(org.id)} + /> + ))} + + + )} + + + + {!isLoading && !hasOrgChanged && projects.length > 0 && ( + + - - {orgsWithBilling.map((org) => ( - handleSelect(org.id)} - /> + Project + + setSelectedProjectId(Number(value))} + size="2" + disabled={projectsLoading} + > + + + {projects.map((project) => ( + + {project.name} + ))} - - - )} - + + + + )} @@ -209,17 +284,11 @@ export function OrgBillingStep({ onNext, onBack }: OrgBillingStepProps) { interface OrgCardProps { name: string; - hasActiveBilling: boolean; isSelected: boolean; onSelect: () => void; } -function OrgCard({ - name, - hasActiveBilling, - isSelected, - onSelect, -}: OrgCardProps) { +function OrgCard({ name, isSelected, onSelect }: OrgCardProps) { return ( {name} - {hasActiveBilling && ( - - - Billing active - - )} state.currentStep); const setCurrentStep = useOnboardingStore((state) => state.setCurrentStep); - const billingEnabled = useFeatureFlag("twig-billing", false); - // Show billing onboarding steps only when billing is enabled - const activeSteps = useMemo(() => { - if (billingEnabled) { - return ONBOARDING_STEPS; - } - return ONBOARDING_STEPS.filter( - (step) => step !== "billing" && step !== "org-billing", - ); - }, [billingEnabled]); - - // Reset to first step if current step is no longer in active steps - useEffect(() => { - if (!activeSteps.includes(currentStep)) { - setCurrentStep(activeSteps[0]); - } - }, [activeSteps, currentStep, setCurrentStep]); + const activeSteps = ONBOARDING_STEPS; const currentIndex = activeSteps.indexOf(currentStep); const isFirstStep = currentIndex === 0; diff --git a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts index 4d165dbd6..d5487c96c 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts @@ -1,12 +1,14 @@ +import { logger } from "@utils/logger"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { OnboardingStep } from "../types"; +const log = logger.scope("onboarding-store"); + interface OnboardingStoreState { currentStep: OnboardingStep; hasCompletedOnboarding: boolean; isConnectingGithub: boolean; - selectedPlan: "free" | "pro" | null; selectedOrgId: string | null; selectedProjectId: number | null; } @@ -17,7 +19,6 @@ interface OnboardingStoreActions { resetOnboarding: () => void; resetSelections: () => void; setConnectingGithub: (isConnecting: boolean) => void; - selectPlan: (plan: "free" | "pro") => void; selectOrg: (orgId: string) => void; selectProjectId: (projectId: number | null) => void; } @@ -28,7 +29,6 @@ const initialState: OnboardingStoreState = { currentStep: "welcome", hasCompletedOnboarding: false, isConnectingGithub: false, - selectedPlan: null, selectedOrgId: null, selectedProjectId: null, }; @@ -39,18 +39,19 @@ export const useOnboardingStore = create()( ...initialState, setCurrentStep: (step) => set({ currentStep: step }), - completeOnboarding: () => set({ hasCompletedOnboarding: true }), + completeOnboarding: () => { + log.info("completeOnboarding"); + set({ hasCompletedOnboarding: true }); + }, resetOnboarding: () => set({ ...initialState }), resetSelections: () => set({ currentStep: "welcome", isConnectingGithub: false, - selectedPlan: null, selectedOrgId: null, selectedProjectId: null, }), setConnectingGithub: (isConnectingGithub) => set({ isConnectingGithub }), - selectPlan: (plan) => set({ selectedPlan: plan }), selectOrg: (orgId) => set({ selectedOrgId: orgId }), selectProjectId: (selectedProjectId) => set({ selectedProjectId }), }), @@ -59,9 +60,6 @@ export const useOnboardingStore = create()( partialize: (state) => ({ currentStep: state.currentStep, hasCompletedOnboarding: state.hasCompletedOnboarding, - selectedPlan: state.selectedPlan, - selectedOrgId: state.selectedOrgId, - selectedProjectId: state.selectedProjectId, }), }, ), diff --git a/apps/code/src/renderer/features/onboarding/types.ts b/apps/code/src/renderer/features/onboarding/types.ts index 91160eefe..759af6b79 100644 --- a/apps/code/src/renderer/features/onboarding/types.ts +++ b/apps/code/src/renderer/features/onboarding/types.ts @@ -1,15 +1,13 @@ export type OnboardingStep = | "welcome" - | "billing" - | "org-billing" + | "org" | "git-integration" | "signals" | "tutorial"; export const ONBOARDING_STEPS: OnboardingStep[] = [ "welcome", - "billing", - "org-billing", + "org", "git-integration", "signals", "tutorial", diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts index 1a73f33b3..5d2d0bcef 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts @@ -1,8 +1,8 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useConnectivity } from "@hooks/useConnectivity"; import { trpcClient } from "@renderer/trpc/client"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import type { Task } from "@shared/types"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { useEffect } from "react"; diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index 7682d60a8..3146f94b8 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -216,7 +216,7 @@ vi.mock("@utils/queryClient", () => ({ setQueriesData: vi.fn(), }, })); -vi.mock("@shared/constants/oauth", () => ({ +vi.mock("@shared/utils/urls", () => ({ getCloudUrlFromRegion: () => "https://api.anthropic.com", })); vi.mock("@utils/session", async () => { diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 9ffc673f0..1717aecb6 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -42,7 +42,6 @@ import { getIsOnline } from "@renderer/stores/connectivityStore"; import { trpcClient } from "@renderer/trpc/client"; import { getGhUserTokenOrThrow } from "@renderer/utils/github"; import { toast } from "@renderer/utils/toast"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import { type CloudTaskPermissionRequestUpdate, type CloudTaskUpdatePayload, @@ -58,6 +57,7 @@ import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud"; import type { AcpMessage, StoredLogEntry } from "@shared/types/session-events"; import { isJsonRpcRequest } from "@shared/types/session-events"; import { getBackoffDelay } from "@shared/utils/backoff"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { buildPermissionToolMetadata, track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { diff --git a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx index 969e782ab..af1a3969c 100644 --- a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx +++ b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx @@ -1,28 +1,36 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { + useAuthStateValue, + useCurrentUser, +} from "@features/auth/hooks/authQueries"; +import { useAuthStore } from "@features/auth/stores/authStore"; import { type SettingsCategory, useSettingsDialogStore, } from "@features/settings/stores/settingsDialogStore"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; +import { useSeat } from "@hooks/useSeat"; import { ArrowLeft, ArrowsClockwise, CaretRight, Cloud, Code, + CreditCard, Folder, GearSix, HardDrives, Keyboard, Palette, Plugs, + SignOut, TrafficSignal, TreeStructure, - User, Wrench, } from "@phosphor-icons/react"; -import { Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; -import { type ReactNode, useEffect } from "react"; +import { Avatar, Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { type ReactNode, useEffect, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -import { AccountSettings } from "./sections/AccountSettings"; import { AdvancedSettings } from "./sections/AdvancedSettings"; import { ClaudeCodeSettings } from "./sections/ClaudeCodeSettings"; import { CloudEnvironmentsSettings } from "./sections/CloudEnvironmentsSettings"; @@ -30,6 +38,7 @@ import { EnvironmentsSettings } from "./sections/environments/EnvironmentsSettin import { GeneralSettings } from "./sections/GeneralSettings"; import { McpServersSettings } from "./sections/McpServersSettings"; import { PersonalizationSettings } from "./sections/PersonalizationSettings"; +import { PlanUsageSettings } from "./sections/PlanUsageSettings"; import { ShortcutsSettings } from "./sections/ShortcutsSettings"; import { SignalSourcesSettings } from "./sections/SignalSourcesSettings"; import { UpdatesSettings } from "./sections/UpdatesSettings"; @@ -45,7 +54,7 @@ interface SidebarItem { const SIDEBAR_ITEMS: SidebarItem[] = [ { id: "general", label: "General", icon: }, - { id: "account", label: "Account", icon: }, + { id: "plan-usage", label: "Plan & Usage", icon: }, { id: "workspaces", label: "Workspaces", icon: }, { id: "worktrees", label: "Worktrees", icon: }, { @@ -78,7 +87,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ const CATEGORY_TITLES: Record = { general: "General", - account: "Account", + "plan-usage": "Plan & Usage", workspaces: "Workspaces", worktrees: "Worktrees", environments: "Environments", @@ -95,7 +104,7 @@ const CATEGORY_TITLES: Record = { const CATEGORY_COMPONENTS: Record = { general: GeneralSettings, - account: AccountSettings, + "plan-usage": PlanUsageSettings, workspaces: WorkspacesSettings, worktrees: WorktreesSettings, environments: EnvironmentsSettings, @@ -113,6 +122,21 @@ const CATEGORY_COMPONENTS: Record = { export function SettingsDialog() { const { isOpen, activeCategory, close, setCategory } = useSettingsDialogStore(); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + const client = useOptionalAuthenticatedClient(); + const { data: user } = useCurrentUser({ client }); + const { seat, planLabel } = useSeat(); + const billingEnabled = useFeatureFlag("posthog-code-billing"); + + const sidebarItems = useMemo( + () => + billingEnabled + ? SIDEBAR_ITEMS + : SIDEBAR_ITEMS.filter((item) => item.id !== "plan-usage"), + [billingEnabled], + ); useHotkeys("escape", close, { enabled: isOpen, @@ -138,13 +162,50 @@ export function SettingsDialog() { const ActiveComponent = CATEGORY_COMPONENTS[activeCategory]; + const initials = user + ? user.first_name && user.last_name + ? `${user.first_name[0]}${user.last_name[0]}`.toUpperCase() + : (user.email?.substring(0, 2).toUpperCase() ?? "U") + : null; + return (
-
+
+
+ + {isAuthenticated && user && initials && ( + + + + + {user.email} + + {seat && ( + + {planLabel} Plan + + )} + + + )} + + )}
-
- - +
- +
+ + + + + + {CATEGORY_TITLES[activeCategory]} + + + + + +
); diff --git a/apps/code/src/renderer/features/settings/components/sections/AccountSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/AccountSettings.tsx index 5ce0c9956..fa9539a9a 100644 --- a/apps/code/src/renderer/features/settings/components/sections/AccountSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/AccountSettings.tsx @@ -4,24 +4,23 @@ import { useAuthStateValue, useCurrentUser, } from "@features/auth/hooks/authQueries"; -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { SettingRow } from "@features/settings/components/SettingRow"; +import { useSeat } from "@hooks/useSeat"; import { SignOut } from "@phosphor-icons/react"; import { Avatar, Badge, Button, Flex, Spinner, Text } from "@radix-ui/themes"; -import { REGION_LABELS } from "@shared/constants/oauth"; +import { REGION_LABELS } from "@shared/types/regions"; export function AccountSettings() { const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - const selectedPlan = useOnboardingStore((state) => state.selectedPlan); const logoutMutation = useLogoutMutation(); const client = useOptionalAuthenticatedClient(); const { data: user, isLoading } = useCurrentUser({ client, enabled: isAuthenticated, }); + const { seat, isPro, planLabel } = useSeat(); const handleLogout = () => { logoutMutation.mutate(); @@ -53,12 +52,7 @@ export function AccountSettings() { return ( - + @@ -75,13 +69,9 @@ export function AccountSettings() { {REGION_LABELS[cloudRegion]} )} - {selectedPlan && ( - - {selectedPlan === "pro" ? "Pro" : "Free"} + {seat && ( + + {planLabel} )} @@ -97,20 +87,6 @@ export function AccountSettings() { Sign out - - - - {selectedPlan === "pro" ? "Pro β€” $200/mo" : "Free"} - - ); } diff --git a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx index 90dc47a07..40c1d4d37 100644 --- a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx @@ -1,5 +1,6 @@ import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { SettingRow } from "@features/settings/components/SettingRow"; +import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { Button, Flex, Switch } from "@radix-ui/themes"; @@ -22,7 +23,10 @@ export function AdvancedSettings() { diff --git a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx index 1b84761cf..887a574c3 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx @@ -8,6 +8,7 @@ import { type SendMessagesWith, useSettingsStore, } from "@features/settings/stores/settingsStore"; +import { ArrowSquareOut } from "@phosphor-icons/react"; import { Button, Flex, @@ -18,18 +19,22 @@ import { Text, } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import type { ThemePreference } from "@stores/themeStore"; import { useThemeStore } from "@stores/themeStore"; import { useMutation, useQuery } from "@tanstack/react-query"; import { track } from "@utils/analytics"; import { playCompletionSound } from "@utils/sounds"; +import { getPostHogUrl } from "@utils/urls"; import { useCallback, useEffect } from "react"; import { toast } from "sonner"; export function GeneralSettings() { const trpcReact = useTRPC(); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + // Appearance state const theme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); @@ -208,10 +213,28 @@ export function GeneralSettings() { [hedgehogMode, setHedgehogMode], ); + const accountUrl = getPostHogUrl("/settings/user"); + return ( + {isAuthenticated && ( + + + + )} + {/* Appearance */} - + Appearance @@ -486,13 +509,11 @@ export function GeneralSettings() { } function HedgehogDescription() { - const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const projectId = useAuthStateValue((state) => state.projectId); - const customizeUrl = - cloudRegion && projectId - ? `${getCloudUrlFromRegion(cloudRegion)}/project/${projectId}/settings/user-customization` - : null; + const customizeUrl = projectId + ? getPostHogUrl(`/project/${projectId}/settings/user-customization`) + : null; return ( diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx new file mode 100644 index 000000000..438dfe384 --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -0,0 +1,476 @@ +import { useSeatStore } from "@features/billing/stores/seatStore"; +import { useSeat } from "@hooks/useSeat"; +import { + ArrowSquareOut, + Check, + CreditCard, + WarningCircle, +} from "@phosphor-icons/react"; +import { + Button, + Callout, + Dialog, + Flex, + Progress, + Spinner, + Text, +} from "@radix-ui/themes"; +import { Tooltip } from "@renderer/components/ui/Tooltip"; +import { useTRPC } from "@renderer/trpc"; +import { useQuery } from "@tanstack/react-query"; +import { getPostHogUrl } from "@utils/urls"; +import { useState } from "react"; + +interface UsageBucket { + used_percent: number; + resets_in_seconds: number; + exceeded: boolean; +} + +function formatResetTime(seconds: number): string { + if (seconds < 3600) return "less than 1 hour"; + if (seconds < 86400) { + const hours = Math.ceil(seconds / 3600); + return hours === 1 ? "1 hour" : `${hours} hours`; + } + const days = Math.ceil(seconds / 86400); + if (days === 1) return "1 day"; + return `${days} days`; +} + +function useUsage() { + const trpc = useTRPC(); + const { data: usage, isLoading } = useQuery( + trpc.llmGateway.usage.queryOptions(), + ); + return { usage: usage ?? null, isLoading }; +} + +export function PlanUsageSettings() { + const { + seat, + isPro, + isCanceling, + activeUntil, + isLoading, + error, + redirectUrl, + } = useSeat(); + const { upgradeToPro, cancelSeat, reactivateSeat, clearError } = + useSeatStore(); + const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); + const { usage, isLoading: usageLoading } = useUsage(); + + const formattedActiveUntil = activeUntil + ? activeUntil.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }) + : null; + + const daysUntilReset = activeUntil + ? Math.max( + 0, + Math.ceil((activeUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)), + ) + : null; + + return ( + + {error && !redirectUrl && ( + + + + + {error} + + )} + + {redirectUrl && ( + + + + + + + + Your organization needs an active billing subscription before + you can select a plan. + + + + + + )} + + + {seat ? ( + <> + + + {isLoading ? : "Reactivate"} + + ) : ( + + ) + ) : ( + + ) + } + /> + + ) : ( + + {isLoading ? ( + + ) : ( + + No plan selected + + )} + + )} + + + + + Usage + + {usageLoading ? ( + + + + ) : usage ? ( + + + + + ) : ( + + + Unable to load usage data + + + )} + + + {isPro && ( + + + Billing + + + + + Manage billing and invoices + + + + + )} + + + Upgrade to Pro + + You are about to subscribe to the Pro plan. Your organization will + be charged $200/month starting immediately. + + + + + Unlimited token usage + + + + Local and cloud execution + + + + All Claude and Codex models + + + + + + + + + + + + ); +} + +interface UsageMeterProps { + label: string; + bucket: UsageBucket; + color?: "red"; +} + +function UsageMeter({ label, bucket, color }: UsageMeterProps) { + const percentage = bucket.used_percent; + + const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; + + return ( + + + + {label} + + + {percentage.toFixed(2)}% + + + + + {bucket.exceeded + ? "Limit exceeded" + : `Resets in ${formatResetTime(bucket.resets_in_seconds)}`} + + + ); +} + +interface PlanCardProps { + name: string; + price: string; + period: string; + features: string[]; + isCurrent: boolean; + resetLabel?: string; + action?: React.ReactNode; +} + +function PlanCard({ + name, + price, + period, + features, + isCurrent, + resetLabel, + action, +}: PlanCardProps) { + return ( + + + + + {isCurrent ? "CURRENT PLAN" : name.toUpperCase()} + + + + {name} + + + {price} + + {period} + + + + {resetLabel && ( + + {resetLabel} + + )} + + + {features.map((feature) => ( + + + + {feature.endsWith("*") ? ( + <> + {feature.slice(0, -1)} + + * + + + ) : ( + feature + )} + + + ))} + + + {action} + + ); +} diff --git a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts index a03ee932c..69660dded 100644 --- a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts +++ b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; export type SettingsCategory = | "general" - | "account" + | "plan-usage" | "workspaces" | "worktrees" | "environments" diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx index ad1542982..24e012bd3 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx @@ -20,7 +20,7 @@ import { } from "@phosphor-icons/react"; import { Box, Dialog, Flex, Text } from "@radix-ui/themes"; import { trpcClient } from "@renderer/trpc/client"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { isMac } from "@utils/platform"; import { useState } from "react"; import "./ProjectSwitcher.css"; diff --git a/apps/code/src/renderer/features/sidebar/components/UpdateBanner.tsx b/apps/code/src/renderer/features/sidebar/components/UpdateBanner.tsx index 2262e4d5d..ab42cc598 100644 --- a/apps/code/src/renderer/features/sidebar/components/UpdateBanner.tsx +++ b/apps/code/src/renderer/features/sidebar/components/UpdateBanner.tsx @@ -57,7 +57,7 @@ export function UpdateBanner() {
- {version ? `v${version} ready` : "Update ready"} + {version ? `${version} ready` : "Update ready"} Restart to apply diff --git a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts index 5a013b0f3..f9f496107 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts @@ -3,7 +3,7 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; import { trpcClient } from "@renderer/trpc/client"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { logger } from "@utils/logger"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; diff --git a/apps/code/src/renderer/hooks/useOrganizations.ts b/apps/code/src/renderer/hooks/useOrganizations.ts index f5463bbcc..6369fb1d5 100644 --- a/apps/code/src/renderer/hooks/useOrganizations.ts +++ b/apps/code/src/renderer/hooks/useOrganizations.ts @@ -5,49 +5,24 @@ import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { useMemo } from "react"; -export interface OrgWithBilling { +export interface OrgInfo { id: string; name: string; slug: string; - has_active_subscription: boolean; - customer_id: string | null; } const organizationKeys = { all: ["organizations"] as const, - withBilling: () => [...organizationKeys.all, "withBilling"] as const, + list: () => [...organizationKeys.all, "list"] as const, }; -async function fetchOrgsWithBilling( - client: PostHogAPIClient, -): Promise { - // Get orgs from the @me endpoint (currentUser.organizations) - // instead of /api/organizations/ which requires higher privileges +async function fetchOrgs(client: PostHogAPIClient): Promise { const user = await client.getCurrentUser(); - const orgs: Array<{ id: string; name: string; slug: string }> = ( - user.organizations ?? [] - ).map((org: { id: string; name: string; slug: string }) => ({ - id: org.id, - name: org.name, - slug: org.slug, - })); - - return Promise.all( - orgs.map(async (org) => { - try { - const billing = await client.getOrgBilling(org.id); - return { - ...org, - has_active_subscription: billing.has_active_subscription, - customer_id: billing.customer_id, - }; - } catch { - return { - ...org, - has_active_subscription: false, - customer_id: null, - }; - } + return (user.organizations ?? []).map( + (org: { id: string; name: string; slug: string }) => ({ + id: org.id, + name: org.name, + slug: org.slug, }), ); } @@ -58,42 +33,33 @@ export function useOrganizations() { const { data: currentUser } = useCurrentUser({ client }); const { - data: orgsWithBilling, + data: orgs, isLoading, error, } = useAuthenticatedQuery( - organizationKeys.withBilling(), - (client) => fetchOrgsWithBilling(client), + organizationKeys.list(), + (client) => fetchOrgs(client), { staleTime: 5 * 60 * 1000 }, ); const effectiveSelectedOrgId = useMemo(() => { if (selectedOrgId) return selectedOrgId; - if (!orgsWithBilling?.length) return null; + if (!orgs?.length) return null; - // Default to the user's currently active org in PostHog const userCurrentOrgId = currentUser?.organization?.id; - if ( - userCurrentOrgId && - orgsWithBilling.some((org) => org.id === userCurrentOrgId) - ) { + if (userCurrentOrgId && orgs.some((org) => org.id === userCurrentOrgId)) { return userCurrentOrgId; } - const withBilling = orgsWithBilling.find( - (org) => org.has_active_subscription, - ); - return (withBilling ?? orgsWithBilling[0]).id; - }, [currentUser?.organization?.id, orgsWithBilling, selectedOrgId]); + return orgs[0].id; + }, [currentUser?.organization?.id, orgs, selectedOrgId]); const sortedOrgs = useMemo(() => { - return [...(orgsWithBilling ?? [])].sort((a, b) => - a.name.localeCompare(b.name), - ); - }, [orgsWithBilling]); + return [...(orgs ?? [])].sort((a, b) => a.name.localeCompare(b.name)); + }, [orgs]); return { - orgsWithBilling: sortedOrgs, + orgs: sortedOrgs, effectiveSelectedOrgId, isLoading, error, diff --git a/apps/code/src/renderer/hooks/useSeat.ts b/apps/code/src/renderer/hooks/useSeat.ts new file mode 100644 index 000000000..4348a856b --- /dev/null +++ b/apps/code/src/renderer/hooks/useSeat.ts @@ -0,0 +1,29 @@ +import { useSeatStore } from "@features/billing/stores/seatStore"; +import { PLAN_PRO, seatHasAccess } from "@shared/types/seat"; + +export function useSeat() { + const seat = useSeatStore((s) => s.seat); + const isLoading = useSeatStore((s) => s.isLoading); + const error = useSeatStore((s) => s.error); + const redirectUrl = useSeatStore((s) => s.redirectUrl); + + const isPro = seat?.plan_key === PLAN_PRO; + const hasAccess = seat ? seatHasAccess(seat.status) : false; + const isCanceling = seat?.status === "canceling"; + const planLabel = isPro ? "Pro" : "Free"; + const activeUntil = seat?.active_until + ? new Date(seat.active_until * 1000) + : null; + + return { + seat, + isLoading, + error, + redirectUrl, + isPro, + hasAccess, + isCanceling, + planLabel, + activeUntil, + }; +} diff --git a/apps/code/src/renderer/utils/urls.ts b/apps/code/src/renderer/utils/urls.ts new file mode 100644 index 000000000..84674f495 --- /dev/null +++ b/apps/code/src/renderer/utils/urls.ts @@ -0,0 +1,8 @@ +import { useAuthStore } from "@features/auth/stores/authStore"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; + +export function getPostHogUrl(path: string): string { + const region = useAuthStore.getState().cloudRegion; + const base = region ? getCloudUrlFromRegion(region) : "http://localhost:8010"; + return `${base}${path.startsWith("/") ? path : `/${path}`}`; +} diff --git a/apps/code/src/shared/constants/oauth.ts b/apps/code/src/shared/constants/oauth.ts index 0a851215a..f59ce0cca 100644 --- a/apps/code/src/shared/constants/oauth.ts +++ b/apps/code/src/shared/constants/oauth.ts @@ -1,4 +1,4 @@ -import type { CloudRegion } from "../types/oauth"; +import type { CloudRegion } from "@shared/types/regions"; export const POSTHOG_US_CLIENT_ID = "HCWoE0aRFMYxIxFNTTwkOORn5LBjOt2GVDzwSw5W"; export const POSTHOG_EU_CLIENT_ID = "AIvijgMS0dxKEmr5z6odvRd8Pkh5vts3nPTzgzU9"; @@ -9,27 +9,10 @@ export const OAUTH_SCOPES = ["*"]; export const OAUTH_SCOPE_VERSION = 4; -export const REGION_LABELS: Record = { - us: "πŸ‡ΊπŸ‡Έ US Cloud", - eu: "πŸ‡ͺπŸ‡Ί EU Cloud", - dev: "πŸ› οΈ Development", -}; - // Token refresh settings export const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000; // 30 minutes before expiry export const TOKEN_REFRESH_FORCE_MS = 60 * 1000; // Force refresh when <1 min to expiry, even with active sessions -export function getCloudUrlFromRegion(region: CloudRegion): string { - switch (region) { - case "us": - return "https://us.posthog.com"; - case "eu": - return "https://eu.posthog.com"; - case "dev": - return "http://localhost:8010"; - } -} - export function getOauthClientIdFromRegion(region: CloudRegion): string { switch (region) { case "us": diff --git a/apps/code/src/shared/types/oauth.ts b/apps/code/src/shared/types/oauth.ts deleted file mode 100644 index 6697c74a3..000000000 --- a/apps/code/src/shared/types/oauth.ts +++ /dev/null @@ -1 +0,0 @@ -export type CloudRegion = "us" | "eu" | "dev"; diff --git a/apps/code/src/shared/types/regions.ts b/apps/code/src/shared/types/regions.ts new file mode 100644 index 000000000..af8cc5b52 --- /dev/null +++ b/apps/code/src/shared/types/regions.ts @@ -0,0 +1,7 @@ +export type CloudRegion = "us" | "eu" | "dev"; + +export const REGION_LABELS: Record = { + us: "πŸ‡ΊπŸ‡Έ US Cloud", + eu: "πŸ‡ͺπŸ‡Ί EU Cloud", + dev: "πŸ› οΈ Development", +}; diff --git a/apps/code/src/shared/types/seat.ts b/apps/code/src/shared/types/seat.ts new file mode 100644 index 000000000..0d138093b --- /dev/null +++ b/apps/code/src/shared/types/seat.ts @@ -0,0 +1,27 @@ +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; +} + +export const SEAT_PRODUCT_KEY = "posthog_code"; +export const PLAN_FREE = "posthog-code-free-20260301"; +export const PLAN_PRO = "posthog-code-200-20260301"; + +export function seatHasAccess(status: SeatStatus): boolean { + return status === "active" || status === "canceling"; +} diff --git a/apps/code/src/shared/utils/urls.ts b/apps/code/src/shared/utils/urls.ts new file mode 100644 index 000000000..71b3e29ea --- /dev/null +++ b/apps/code/src/shared/utils/urls.ts @@ -0,0 +1,12 @@ +import type { CloudRegion } from "@shared/types/regions"; + +export function getCloudUrlFromRegion(region: CloudRegion): string { + switch (region) { + case "us": + return "https://us.posthog.com"; + case "eu": + return "https://eu.posthog.com"; + case "dev": + return "http://localhost:8010"; + } +} diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index 980b58e6c..f02554dc6 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -7,9 +7,9 @@ import type { TaskRun, TaskRunArtifact, } from "./types"; -import { getLlmGatewayUrl } from "./utils/gateway"; +import { getGatewayUsageUrl, getLlmGatewayUrl } from "./utils/gateway"; -export { getLlmGatewayUrl }; +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 5fe915258..f12745a4e 100644 --- a/packages/agent/src/utils/gateway.ts +++ b/packages/agent/src/utils/gateway.ts @@ -1,23 +1,31 @@ export type GatewayProduct = "posthog_code" | "background_agents"; -export function getLlmGatewayUrl( - posthogHost: string, - product: GatewayProduct = "posthog_code", -): string { +function getGatewayBaseUrl(posthogHost: string): string { const url = new URL(posthogHost); const hostname = url.hostname; - // Local development (normalize 127.0.0.1 to localhost) if (hostname === "localhost" || hostname === "127.0.0.1") { - return `${url.protocol}//localhost:3308/${product}`; + return `${url.protocol}//localhost:3308`; } - // Docker containers accessing host if (hostname === "host.docker.internal") { - return `${url.protocol}//host.docker.internal:3308/${product}`; + return `${url.protocol}//host.docker.internal:3308`; } - // Production - extract region from hostname, default to US const region = hostname.match(/^(us|eu)\.posthog\.com$/)?.[1] ?? "us"; - return `https://gateway.${region}.posthog.com/${product}`; + return `https://gateway.${region}.posthog.com`; +} + +export function getLlmGatewayUrl( + posthogHost: string, + product: GatewayProduct = "posthog_code", +): string { + return `${getGatewayBaseUrl(posthogHost)}/${product}`; +} + +export function getGatewayUsageUrl( + posthogHost: string, + product: GatewayProduct = "posthog_code", +): string { + return `${getGatewayBaseUrl(posthogHost)}/v1/usage/${product}`; }