From efc5c969651d00f2b09bc0a49a5ac8f5410a2129 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 17 Mar 2026 15:15:34 -0700 Subject: [PATCH 01/32] Implement billing --- apps/code/src/renderer/api/posthogClient.ts | 185 ++++++++++++++---- .../features/auth/stores/authStore.test.ts | 14 ++ .../features/auth/stores/authStore.ts | 5 + .../features/billing/stores/seatStore.ts | 148 ++++++++++++++ .../onboarding/components/BillingStep.tsx | 85 +++++++- .../onboarding/components/OnboardingFlow.tsx | 12 +- .../onboarding/components/OrgBillingStep.tsx | 20 +- .../src/renderer/features/onboarding/types.ts | 2 +- .../components/sections/AccountSettings.tsx | 146 +++++++++++--- .../src/renderer/hooks/useOrganizations.ts | 69 ++----- apps/code/src/renderer/hooks/useSeat.ts | 29 +++ apps/code/src/shared/types/seat.ts | 27 +++ 12 files changed, 602 insertions(+), 140 deletions(-) create mode 100644 apps/code/src/renderer/features/billing/stores/seatStore.ts create mode 100644 apps/code/src/renderer/hooks/useSeat.ts create mode 100644 apps/code/src/shared/types/seat.ts diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index a28eb3bef..868a0534a 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -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,140 @@ export class PostHogAPIClient { } } + async getMySeat(): Promise { + try { + const url = new URL(`${this.api.baseUrl}/api/code/seats/me/`); + url.searchParams.set("product_key", SEAT_PRODUCT_KEY); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: "/api/code/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 url = new URL(`${this.api.baseUrl}/api/code/seats/`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: "/api/code/seats/", + overrides: { + body: JSON.stringify({ + product_key: SEAT_PRODUCT_KEY, + plan_key: planKey, + }), + }, + }); + 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/code/seats/me/`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: "/api/code/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/code/seats/me/`); + url.searchParams.set("product_key", SEAT_PRODUCT_KEY); + await this.api.fetcher.fetch({ + method: "delete", + url, + path: "/api/code/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/code/seats/me/reactivate/`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: "/api/code/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) { + const redirectUrl = + typeof parsed.body.redirect_url === "string" + ? parsed.body.redirect_url + : `${this.api.baseUrl}/organization/billing`; + throw new SeatSubscriptionRequiredError(redirectUrl); + } + 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/stores/authStore.test.ts b/apps/code/src/renderer/features/auth/stores/authStore.test.ts index cd5ce4e05..9821c9f48 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.test.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.test.ts @@ -38,6 +38,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", () => ({ diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 76f36966b..b5974d48d 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -1,3 +1,4 @@ +import { useSeatStore } from "@features/billing/stores/seatStore"; import { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc/client"; import { getCloudUrlFromRegion } from "@shared/constants/oauth"; @@ -281,6 +282,9 @@ export const useAuthStore = create((set, get) => ({ completeOnboarding: () => { set({ hasCompletedOnboarding: true }); + if (!useSeatStore.getState().seat) { + useSeatStore.getState().provisionFreeSeat(); + } }, selectPlan: (plan: "free" | "pro") => { @@ -294,6 +298,7 @@ export const useAuthStore = create((set, get) => ({ logout: async () => { track(ANALYTICS_EVENTS.USER_LOGGED_OUT); sessionResetCallback?.(); + useSeatStore.getState().reset(); clearAuthenticatedRendererState({ clearAllQueries: true }); await trpcClient.auth.logout.mutate(); useNavigationStore.getState().navigateToTaskInput(); 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..6fad4e150 --- /dev/null +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -0,0 +1,148 @@ +import { useAuthStore } from "@features/auth/stores/authStore"; +import type { SeatSubscriptionRequiredError } from "@renderer/api/posthogClient"; +import type { SeatData } from "@shared/types/seat"; +import { PLAN_FREE, PLAN_PRO } from "@shared/types/seat"; +import { electronStorage } from "@utils/electronStorage"; +import { logger } from "@utils/logger"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +const log = logger.scope("seat-store"); + +interface SeatStoreState { + seat: SeatData | null; + isLoading: boolean; + error: string | null; + redirectUrl: string | null; +} + +interface SeatStoreActions { + fetchSeat: () => Promise; + provisionFreeSeat: () => Promise; + upgradeToPro: () => Promise; + cancelSeat: () => Promise; + reactivateSeat: () => Promise; + clearError: () => void; + reset: () => void; +} + +type SeatStore = SeatStoreState & SeatStoreActions; + +function getClient() { + const client = useAuthStore.getState().client; + if (!client) { + throw new Error("Not authenticated"); + } + return client; +} + +function handleSeatError( + error: unknown, + set: (state: Partial) => void, +): void { + if (error instanceof Error) { + if ( + error.name === "SeatSubscriptionRequiredError" && + "redirectUrl" in error + ) { + set({ + isLoading: false, + error: "Billing subscription required", + redirectUrl: (error as SeatSubscriptionRequiredError).redirectUrl, + }); + return; + } + if (error.name === "SeatPaymentFailedError") { + set({ isLoading: false, error: error.message }); + return; + } + log.error("Seat operation failed", error); + set({ isLoading: false, error: error.message }); + return; + } + log.error("Seat operation failed", error); + set({ isLoading: false, error: "An unexpected error occurred" }); +} + +const initialState: SeatStoreState = { + seat: null, + isLoading: false, + error: null, + redirectUrl: null, +}; + +export const useSeatStore = create()( + persist( + (set) => ({ + ...initialState, + + fetchSeat: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + try { + const client = getClient(); + const seat = await client.getMySeat(); + set({ seat, isLoading: false }); + } catch (error) { + handleSeatError(error, set); + } + }, + + provisionFreeSeat: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + try { + const client = getClient(); + const seat = await client.createSeat(PLAN_FREE); + set({ seat, isLoading: false }); + } catch (error) { + handleSeatError(error, set); + } + }, + + upgradeToPro: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + try { + const client = getClient(); + const currentSeat = useSeatStore.getState().seat; + const seat = currentSeat + ? await client.upgradeSeat(PLAN_PRO) + : await client.createSeat(PLAN_PRO); + set({ seat, isLoading: false }); + } catch (error) { + handleSeatError(error, set); + } + }, + + cancelSeat: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + try { + const client = 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 { + const client = getClient(); + const seat = await client.reactivateSeat(); + set({ seat, isLoading: false }); + } catch (error) { + handleSeatError(error, set); + } + }, + + clearError: () => set({ error: null, redirectUrl: null }), + + reset: () => set(initialState), + }), + { + name: "posthog-code-seat", + storage: electronStorage, + partialize: (state) => ({ seat: state.seat }), + }, + ), +); diff --git a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx b/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx index d7ff782ac..6eb816611 100644 --- a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx @@ -1,6 +1,14 @@ +import { useSeatStore } from "@features/billing/stores/seatStore"; 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 { useSeat } from "@hooks/useSeat"; +import { + ArrowLeft, + ArrowRight, + ArrowSquareOut, + Check, + WarningCircle, +} from "@phosphor-icons/react"; +import { Badge, Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; import codeLogo from "@renderer/assets/images/code.svg"; import { useEffect } from "react"; @@ -26,6 +34,8 @@ const PRO_FEATURES: PlanFeature[] = [ export function BillingStep({ onNext, onBack }: BillingStepProps) { const selectedPlan = useOnboardingStore((state) => state.selectedPlan); const selectPlan = useOnboardingStore((state) => state.selectPlan); + const { isLoading, error, redirectUrl } = useSeat(); + const { provisionFreeSeat, upgradeToPro, clearError } = useSeatStore(); useEffect(() => { if (!selectedPlan) { @@ -33,8 +43,21 @@ export function BillingStep({ onNext, onBack }: BillingStepProps) { } }, [selectedPlan, selectPlan]); - const handleContinue = () => { - onNext(); + useEffect(() => { + return () => clearError(); + }, [clearError]); + + const handleContinue = async () => { + if (selectedPlan === "free") { + await provisionFreeSeat(); + } else { + await upgradeToPro(); + } + + const storeState = useSeatStore.getState(); + if (!storeState.error) { + onNext(); + } }; return ( @@ -75,8 +98,43 @@ export function BillingStep({ onNext, onBack }: BillingStepProps) { > Choose your plan + + {error && !redirectUrl && ( + + + + + {error} + + )} + + {redirectUrl && ( + + + + + + + + Your organization needs an active billing subscription + before you can select a plan. + + + + + + )} + - {/* Free Plan */} selectPlan("free")} /> - {/* Pro Plan */} Back - diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx index 2cb8a7d10..31011e790 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx @@ -98,29 +98,29 @@ export function OnboardingFlow() { )} - {currentStep === "billing" && ( + {currentStep === "org-billing" && ( - + )} - {currentStep === "org-billing" && ( + {currentStep === "billing" && ( - + )} diff --git a/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx b/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx index 7c600868a..3eb9cd412 100644 --- a/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx @@ -42,8 +42,7 @@ export function OrgBillingStep({ onNext, onBack }: OrgBillingStepProps) { }, }); - const { orgsWithBilling, effectiveSelectedOrgId, isLoading, error } = - useOrganizations(); + const { orgs, effectiveSelectedOrgId, isLoading, error } = useOrganizations(); const currentUserOrgId = currentUser?.organization?.id; @@ -164,11 +163,10 @@ export function OrgBillingStep({ onNext, onBack }: OrgBillingStepProps) { transition={{ duration: 0.2 }} > - {orgsWithBilling.map((org) => ( + {orgs.map((org) => ( handleSelect(org.id)} /> @@ -209,17 +207,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.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, + isCanceling, + planLabel, + activeUntil, + isLoading: seatLoading, + error: seatError, + redirectUrl, + } = useSeat(); + const { upgradeToPro, cancelSeat, reactivateSeat, clearError } = + useSeatStore(); const handleLogout = () => { logoutMutation.mutate(); @@ -51,6 +71,14 @@ export function AccountSettings() { ? `${user.first_name[0]}${user.last_name[0]}`.toUpperCase() : (user.email?.substring(0, 2).toUpperCase() ?? "U"); + const formattedActiveUntil = activeUntil + ? activeUntil.toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }) + : null; + return ( )} - {selectedPlan && ( - - {selectedPlan === "pro" ? "Pro" : "Free"} + {seat && ( + + {planLabel} )} @@ -98,19 +122,95 @@ export function AccountSettings() { - - - {selectedPlan === "pro" ? "Pro — $200/mo" : "Free"} - + + + {seatLoading ? ( + + ) : seat ? ( + <> + + {isPro ? `Pro — $200/mo` : "Free"} + + {isCanceling && formattedActiveUntil && ( + + Cancels {formattedActiveUntil} + + )} + + ) : ( + + No plan + + )} + + + {seat && ( + + + {seatError && !redirectUrl && ( + + {seatError} + + )} + {redirectUrl && ( + + )} + {!redirectUrl && isCanceling && ( + + )} + {!redirectUrl && !isCanceling && isPro && ( + + )} + {!redirectUrl && !isCanceling && !isPro && ( + + )} + + + )} ); } diff --git a/apps/code/src/renderer/hooks/useOrganizations.ts b/apps/code/src/renderer/hooks/useOrganizations.ts index f5463bbcc..7e22763c2 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,34 @@ 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/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"; +} From 2b12246c86181cf751cdd4afac574a7220f93c36 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 18 Mar 2026 21:23:07 -0700 Subject: [PATCH 02/32] wip --- apps/code/src/renderer/api/posthogClient.ts | 11 ++- .../features/billing/stores/seatStore.ts | 90 +++++++++++++++---- 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 868a0534a..6a980de04 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1843,12 +1843,11 @@ export class PostHogAPIClient { const parsed = this.parseFetcherError(error); if (parsed) { - if (parsed.status === 400) { - const redirectUrl = - typeof parsed.body.redirect_url === "string" - ? parsed.body.redirect_url - : `${this.api.baseUrl}/organization/billing`; - throw new SeatSubscriptionRequiredError(redirectUrl); + if ( + parsed.status === 400 && + typeof parsed.body.redirect_url === "string" + ) { + throw new SeatSubscriptionRequiredError(parsed.body.redirect_url); } if (parsed.status === 402) { const message = diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts index 6fad4e150..c9061d761 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -1,5 +1,4 @@ import { useAuthStore } from "@features/auth/stores/authStore"; -import type { SeatSubscriptionRequiredError } from "@renderer/api/posthogClient"; import type { SeatData } from "@shared/types/seat"; import { PLAN_FREE, PLAN_PRO } from "@shared/types/seat"; import { electronStorage } from "@utils/electronStorage"; @@ -36,32 +35,70 @@ function getClient() { return client; } +function parseFetcherError( + error: Error, +): { status: number; body: Record } | 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: {} }; + } +} + function handleSeatError( error: unknown, set: (state: Partial) => void, ): void { - if (error instanceof Error) { - if ( - error.name === "SeatSubscriptionRequiredError" && - "redirectUrl" in error - ) { + if (!(error instanceof Error)) { + log.error("Seat operation failed", error); + set({ isLoading: false, error: "An unexpected error occurred" }); + return; + } + + if ( + "redirectUrl" in error && + typeof (error as { redirectUrl: unknown }).redirectUrl === "string" + ) { + set({ + isLoading: false, + error: "Billing subscription required", + redirectUrl: (error as { redirectUrl: string }).redirectUrl, + }); + return; + } + + const parsed = parseFetcherError(error); + if (parsed) { + if (parsed.status === 400 && typeof parsed.body.redirect_url === "string") { set({ isLoading: false, - error: "Billing subscription required", - redirectUrl: (error as SeatSubscriptionRequiredError).redirectUrl, + error: + typeof parsed.body.error === "string" + ? parsed.body.error + : "Billing subscription required", + redirectUrl: parsed.body.redirect_url, }); return; } - if (error.name === "SeatPaymentFailedError") { - set({ isLoading: false, error: error.message }); + if (parsed.status === 402) { + set({ + isLoading: false, + error: + typeof parsed.body.error === "string" + ? parsed.body.error + : "Payment failed", + }); return; } - log.error("Seat operation failed", error); - set({ isLoading: false, error: error.message }); - return; } + log.error("Seat operation failed", error); - set({ isLoading: false, error: "An unexpected error occurred" }); + set({ isLoading: false, error: error.message }); } const initialState: SeatStoreState = { @@ -91,6 +128,16 @@ export const useSeatStore = create()( set({ isLoading: true, error: null, redirectUrl: null }); try { const client = getClient(); + const existing = await client.getMySeat(); + if (existing) { + if (existing.plan_key === PLAN_FREE) { + set({ seat: existing, isLoading: false }); + return; + } + const seat = await client.upgradeSeat(PLAN_FREE); + set({ seat, isLoading: false }); + return; + } const seat = await client.createSeat(PLAN_FREE); set({ seat, isLoading: false }); } catch (error) { @@ -102,10 +149,17 @@ export const useSeatStore = create()( set({ isLoading: true, error: null, redirectUrl: null }); try { const client = getClient(); - const currentSeat = useSeatStore.getState().seat; - const seat = currentSeat - ? await client.upgradeSeat(PLAN_PRO) - : await client.createSeat(PLAN_PRO); + 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); From db581981ce1c63d1821d16d56b0a94fe8e338463 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 15:41:52 -0700 Subject: [PATCH 03/32] settings refactor for plans --- .../features/billing/stores/seatStore.ts | 20 +- .../settings/components/SettingsDialog.tsx | 187 ++++++--- .../components/sections/AccountSettings.tsx | 132 +------ .../components/sections/GeneralSettings.tsx | 28 +- .../components/sections/PlanUsageSettings.tsx | 361 ++++++++++++++++++ .../settings/stores/settingsDialogStore.ts | 2 +- 6 files changed, 537 insertions(+), 193 deletions(-) create mode 100644 apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts index c9061d761..e840743a7 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -1,4 +1,5 @@ import { useAuthStore } from "@features/auth/stores/authStore"; +import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import type { SeatData } from "@shared/types/seat"; import { PLAN_FREE, PLAN_PRO } from "@shared/types/seat"; import { electronStorage } from "@utils/electronStorage"; @@ -50,6 +51,12 @@ function parseFetcherError( } } +function getBillingUrl(): string { + const region = useAuthStore.getState().cloudRegion; + const base = region ? getCloudUrlFromRegion(region) : "http://localhost:8010"; + return `${base}/organization/billing`; +} + function handleSeatError( error: unknown, set: (state: Partial) => void, @@ -60,6 +67,8 @@ function handleSeatError( return; } + const billingUrl = getBillingUrl(); + if ( "redirectUrl" in error && typeof (error as { redirectUrl: unknown }).redirectUrl === "string" @@ -67,7 +76,7 @@ function handleSeatError( set({ isLoading: false, error: "Billing subscription required", - redirectUrl: (error as { redirectUrl: string }).redirectUrl, + redirectUrl: billingUrl, }); return; } @@ -81,7 +90,7 @@ function handleSeatError( typeof parsed.body.error === "string" ? parsed.body.error : "Billing subscription required", - redirectUrl: parsed.body.redirect_url, + redirectUrl: billingUrl, }); return; } @@ -130,12 +139,7 @@ export const useSeatStore = create()( const client = getClient(); const existing = await client.getMySeat(); if (existing) { - if (existing.plan_key === PLAN_FREE) { - set({ seat: existing, isLoading: false }); - return; - } - const seat = await client.upgradeSeat(PLAN_FREE); - set({ seat, isLoading: false }); + set({ seat: existing, isLoading: false }); return; } const seat = await client.createSeat(PLAN_FREE); diff --git a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx index 969e782ab..3a3fb2255 100644 --- a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx +++ b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx @@ -1,28 +1,31 @@ +import { useAuthStore } from "@features/auth/stores/authStore"; import { type SettingsCategory, useSettingsDialogStore, } from "@features/settings/stores/settingsDialogStore"; +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 { Avatar, Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { useQuery } from "@tanstack/react-query"; import { type ReactNode, useEffect } 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 +33,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 +49,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 +82,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 +99,7 @@ const CATEGORY_TITLES: Record = { const CATEGORY_COMPONENTS: Record = { general: GeneralSettings, - account: AccountSettings, + "plan-usage": PlanUsageSettings, workspaces: WorkspacesSettings, worktrees: WorktreesSettings, environments: EnvironmentsSettings, @@ -113,6 +117,17 @@ const CATEGORY_COMPONENTS: Record = { export function SettingsDialog() { const { isOpen, activeCategory, close, setCategory } = useSettingsDialogStore(); + const { client, isAuthenticated } = useAuthStore(); + const { seat, planLabel } = useSeat(); + + const { data: user } = useQuery({ + queryKey: ["currentUser"], + queryFn: async () => { + if (!client) return null; + return await client.getCurrentUser(); + }, + enabled: !!client && isAuthenticated, + }); useHotkeys("escape", close, { enabled: isOpen, @@ -138,13 +153,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 923cec1cd..a8b8a5c17 100644 --- a/apps/code/src/renderer/features/settings/components/sections/AccountSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/AccountSettings.tsx @@ -4,19 +4,9 @@ import { useAuthStateValue, useCurrentUser, } from "@features/auth/hooks/authQueries"; -import { useSeatStore } from "@features/billing/stores/seatStore"; -import { SettingRow } from "@features/settings/components/SettingRow"; import { useSeat } from "@hooks/useSeat"; -import { ArrowSquareOut, SignOut } from "@phosphor-icons/react"; -import { - Avatar, - Badge, - Button, - Callout, - Flex, - Spinner, - Text, -} from "@radix-ui/themes"; +import { SignOut } from "@phosphor-icons/react"; +import { Avatar, Badge, Button, Flex, Spinner, Text } from "@radix-ui/themes"; import { REGION_LABELS } from "@shared/constants/oauth"; export function AccountSettings() { @@ -30,18 +20,7 @@ export function AccountSettings() { client, enabled: isAuthenticated, }); - const { - seat, - isPro, - isCanceling, - planLabel, - activeUntil, - isLoading: seatLoading, - error: seatError, - redirectUrl, - } = useSeat(); - const { upgradeToPro, cancelSeat, reactivateSeat, clearError } = - useSeatStore(); + const { seat, isPro, planLabel } = useSeat(); const handleLogout = () => { logoutMutation.mutate(); @@ -71,22 +50,9 @@ export function AccountSettings() { ? `${user.first_name[0]}${user.last_name[0]}`.toUpperCase() : (user.email?.substring(0, 2).toUpperCase() ?? "U"); - const formattedActiveUntil = activeUntil - ? activeUntil.toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - }) - : null; - return ( - + @@ -121,96 +87,6 @@ export function AccountSettings() { Sign out - - - - {seatLoading ? ( - - ) : seat ? ( - <> - - {isPro ? `Pro — $200/mo` : "Free"} - - {isCanceling && formattedActiveUntil && ( - - Cancels {formattedActiveUntil} - - )} - - ) : ( - - No plan - - )} - - - - {seat && ( - - - {seatError && !redirectUrl && ( - - {seatError} - - )} - {redirectUrl && ( - - )} - {!redirectUrl && isCanceling && ( - - )} - {!redirectUrl && !isCanceling && isPro && ( - - )} - {!redirectUrl && !isCanceling && !isPro && ( - - )} - - - )} ); } 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..cd1dff678 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, @@ -30,6 +31,11 @@ import { toast } from "sonner"; export function GeneralSettings() { const trpcReact = useTRPC(); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + // Appearance state const theme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); @@ -208,10 +214,30 @@ export function GeneralSettings() { [hedgehogMode, setHedgehogMode], ); + const accountUrl = cloudRegion + ? `${getCloudUrlFromRegion(cloudRegion)}/settings/user` + : null; + return ( + {isAuthenticated && accountUrl && ( + + + + )} + {/* Appearance */} - + Appearance 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..c081fff69 --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -0,0 +1,361 @@ +import { useAuthStore } from "@features/auth/stores/authStore"; +import { useSeatStore } from "@features/billing/stores/seatStore"; +import { useSeat } from "@hooks/useSeat"; +import { + ArrowSquareOut, + CreditCard, + WarningCircle, +} from "@phosphor-icons/react"; +import { + Button, + Callout, + Flex, + Progress, + Spinner, + Text, +} from "@radix-ui/themes"; +import { getCloudUrlFromRegion } from "@shared/constants/oauth"; + +export function PlanUsageSettings() { + const { + seat, + isPro, + isCanceling, + activeUntil, + isLoading, + error, + redirectUrl, + } = useSeat(); + const { upgradeToPro, cancelSeat, reactivateSeat, clearError } = + useSeatStore(); + + 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 + + {isPro ? ( + + + + Token usage + + + Unlimited + + +
+
+ +
+ + Unlimited tokens included with Pro (go crazy) + + + ) : ( + + + + Token usage + + + 0% + + + + + 0 tokens used this period + + + )} + + + {isPro && ( + + + Billing + + + + + Manage billing and invoices + + + + + )} + + ); +} + +interface PlanCardProps { + name: string; + price: string; + period: string; + description: string; + isCurrent: boolean; + resetLabel?: string; + action?: React.ReactNode; +} + +function PlanCard({ + name, + price, + period, + description, + isCurrent, + resetLabel, + action, +}: PlanCardProps) { + return ( + + + + {isCurrent ? "CURRENT PLAN" : name.toUpperCase()} + + + + {name} + + + {price} + + {period} + + + + {resetLabel ? ( + + {resetLabel} + + ) : ( + + {description} + + )} + + {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" From 8bb347858ae8b1c95515477401da19739e2d2309 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 15:57:05 -0700 Subject: [PATCH 04/32] Add getPostHogUrl util for region-aware URLs --- .../features/billing/stores/seatStore.ts | 6 ++---- .../components/sections/GeneralSettings.tsx | 17 ++++++----------- .../components/sections/PlanUsageSettings.tsx | 10 +++------- apps/code/src/shared/constants/oauth.ts | 1 + apps/code/src/shared/utils/urls.ts | 10 ++++++++++ 5 files changed, 22 insertions(+), 22 deletions(-) create mode 100644 apps/code/src/shared/utils/urls.ts diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts index e840743a7..889acefda 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -1,5 +1,5 @@ import { useAuthStore } from "@features/auth/stores/authStore"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import { getPostHogUrl } from "@shared/utils/urls"; import type { SeatData } from "@shared/types/seat"; import { PLAN_FREE, PLAN_PRO } from "@shared/types/seat"; import { electronStorage } from "@utils/electronStorage"; @@ -52,9 +52,7 @@ function parseFetcherError( } function getBillingUrl(): string { - const region = useAuthStore.getState().cloudRegion; - const base = region ? getCloudUrlFromRegion(region) : "http://localhost:8010"; - return `${base}/organization/billing`; + return getPostHogUrl("/organization/billing"); } function handleSeatError( 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 cd1dff678..d8c1a536a 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx @@ -19,7 +19,7 @@ import { Text, } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import { getPostHogUrl } from "@shared/utils/urls"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import type { ThemePreference } from "@stores/themeStore"; import { useThemeStore } from "@stores/themeStore"; @@ -34,7 +34,6 @@ export function GeneralSettings() { const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); - const cloudRegion = useAuthStateValue((state) => state.cloudRegion); // Appearance state const theme = useThemeStore((state) => state.theme); @@ -214,13 +213,11 @@ export function GeneralSettings() { [hedgehogMode, setHedgehogMode], ); - const accountUrl = cloudRegion - ? `${getCloudUrlFromRegion(cloudRegion)}/settings/user` - : null; + const accountUrl = getPostHogUrl("/settings/user"); return ( - {isAuthenticated && accountUrl && ( + {isAuthenticated && ( 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 index c081fff69..bda3bc87d 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -1,4 +1,3 @@ -import { useAuthStore } from "@features/auth/stores/authStore"; import { useSeatStore } from "@features/billing/stores/seatStore"; import { useSeat } from "@hooks/useSeat"; import { @@ -14,7 +13,7 @@ import { Spinner, Text, } from "@radix-ui/themes"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import { getPostHogUrl } from "@shared/utils/urls"; export function PlanUsageSettings() { const { @@ -272,11 +271,8 @@ export function PlanUsageSettings() { size="1" variant="outline" onClick={() => { - const region = useAuthStore.getState().cloudRegion; - const base = region - ? getCloudUrlFromRegion(region) - : "http://localhost:8010"; - window.open(`${base}/organization/billing`, "_blank"); + const url = getPostHogUrl("/organization/billing"); + window.open(url, "_blank"); }} > Open diff --git a/apps/code/src/shared/constants/oauth.ts b/apps/code/src/shared/constants/oauth.ts index 0a851215a..8d3f913c5 100644 --- a/apps/code/src/shared/constants/oauth.ts +++ b/apps/code/src/shared/constants/oauth.ts @@ -30,6 +30,7 @@ export function getCloudUrlFromRegion(region: CloudRegion): string { } } + export function getOauthClientIdFromRegion(region: CloudRegion): string { switch (region) { case "us": diff --git a/apps/code/src/shared/utils/urls.ts b/apps/code/src/shared/utils/urls.ts new file mode 100644 index 000000000..ea9bd0be2 --- /dev/null +++ b/apps/code/src/shared/utils/urls.ts @@ -0,0 +1,10 @@ +import { useAuthStore } from "@features/auth/stores/authStore"; +import { getCloudUrlFromRegion } from "@shared/constants/oauth"; + +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}`}`; +} From 478fe124b02846fc7e134a3ebf4d061fbdfc5802 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 16:01:29 -0700 Subject: [PATCH 05/32] Move getCloudUrlFromRegion to shared/utils/urls --- .../src/main/services/github-integration/service.ts | 2 +- .../src/main/services/linear-integration/service.ts | 2 +- apps/code/src/main/services/oauth/service.ts | 2 +- .../src/renderer/features/auth/stores/authStore.ts | 2 +- .../inbox/components/detail/ReportDetailPane.tsx | 2 +- .../features/sessions/service/service.test.ts | 2 +- .../renderer/features/sessions/service/service.ts | 2 +- .../features/sidebar/components/ProjectSwitcher.tsx | 2 +- apps/code/src/shared/constants/oauth.ts | 12 ------------ apps/code/src/shared/utils/urls.ts | 13 ++++++++++++- 10 files changed, 20 insertions(+), 21 deletions(-) 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/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/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index b5974d48d..70da7b447 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -1,7 +1,7 @@ import { useSeatStore } from "@features/billing/stores/seatStore"; 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 { ANALYTICS_EVENTS } from "@shared/types/analytics"; import type { CloudRegion } from "@shared/types/oauth"; import { useNavigationStore } from "@stores/navigationStore"; 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/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..dc3b05b8a 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -42,7 +42,7 @@ 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 { getCloudUrlFromRegion } from "@shared/utils/urls"; import { type CloudTaskPermissionRequestUpdate, type CloudTaskUpdatePayload, 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/shared/constants/oauth.ts b/apps/code/src/shared/constants/oauth.ts index 8d3f913c5..9144fe789 100644 --- a/apps/code/src/shared/constants/oauth.ts +++ b/apps/code/src/shared/constants/oauth.ts @@ -19,18 +19,6 @@ export const REGION_LABELS: Record = { 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/utils/urls.ts b/apps/code/src/shared/utils/urls.ts index ea9bd0be2..cd67f32a0 100644 --- a/apps/code/src/shared/utils/urls.ts +++ b/apps/code/src/shared/utils/urls.ts @@ -1,5 +1,16 @@ import { useAuthStore } from "@features/auth/stores/authStore"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import type { CloudRegion } from "@shared/types/oauth"; + +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 getPostHogUrl(path: string): string { const region = useAuthStore.getState().cloudRegion; From 41e9fd99fdfb20052b232874ac0bc3e1f09257de Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 19:14:17 -0700 Subject: [PATCH 06/32] Move CloudRegion and REGION_LABELS to shared/types/regions --- .../src/renderer/features/auth/components/AuthScreen.tsx | 4 ++-- .../renderer/features/auth/components/RegionSelect.tsx | 2 +- apps/code/src/renderer/features/auth/stores/authStore.ts | 2 +- .../settings/components/sections/AccountSettings.tsx | 2 +- apps/code/src/shared/constants/oauth.ts | 8 +------- apps/code/src/shared/types/oauth.ts | 1 - apps/code/src/shared/types/regions.ts | 7 +++++++ apps/code/src/shared/utils/urls.ts | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) delete mode 100644 apps/code/src/shared/types/oauth.ts create mode 100644 apps/code/src/shared/types/regions.ts diff --git a/apps/code/src/renderer/features/auth/components/AuthScreen.tsx b/apps/code/src/renderer/features/auth/components/AuthScreen.tsx index 3ed465303..6c1924f0e 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 { REGION_LABELS } from "@shared/types/regions"; +import type { CloudRegion } 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/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 70da7b447..9bc940d0c 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -3,7 +3,7 @@ import { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc/client"; import { getCloudUrlFromRegion } from "@shared/utils/urls"; 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 { identifyUser, resetUser, track } from "@utils/analytics"; import { logger } from "@utils/logger"; 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 a8b8a5c17..fa9539a9a 100644 --- a/apps/code/src/renderer/features/settings/components/sections/AccountSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/AccountSettings.tsx @@ -7,7 +7,7 @@ import { 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( diff --git a/apps/code/src/shared/constants/oauth.ts b/apps/code/src/shared/constants/oauth.ts index 9144fe789..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,12 +9,6 @@ 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 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/utils/urls.ts b/apps/code/src/shared/utils/urls.ts index cd67f32a0..82d99bfaf 100644 --- a/apps/code/src/shared/utils/urls.ts +++ b/apps/code/src/shared/utils/urls.ts @@ -1,5 +1,5 @@ import { useAuthStore } from "@features/auth/stores/authStore"; -import type { CloudRegion } from "@shared/types/oauth"; +import type { CloudRegion } from "@shared/types/regions"; export function getCloudUrlFromRegion(region: CloudRegion): string { switch (region) { From 3842e34859bd69650269f1dd15fc883aef62d6d4 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 20:50:24 -0700 Subject: [PATCH 07/32] Add upgrade confirmation dialog and plan features --- apps/code/src/renderer/api/posthogClient.ts | 20 +-- .../onboarding/components/BillingStep.tsx | 49 +++++++- .../components/sections/PlanUsageSettings.tsx | 116 +++++++++++++----- 3 files changed, 140 insertions(+), 45 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 6a980de04..aec5f9395 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1728,12 +1728,12 @@ export class PostHogAPIClient { async getMySeat(): Promise { try { - const url = new URL(`${this.api.baseUrl}/api/code/seats/me/`); + 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/code/seats/me/", + path: "/api/seats/me/", }); return (await response.json()) as SeatData; } catch (error) { @@ -1746,11 +1746,11 @@ export class PostHogAPIClient { async createSeat(planKey: string): Promise { try { - const url = new URL(`${this.api.baseUrl}/api/code/seats/`); + const url = new URL(`${this.api.baseUrl}/api/seats/`); const response = await this.api.fetcher.fetch({ method: "post", url, - path: "/api/code/seats/", + path: "/api/seats/", overrides: { body: JSON.stringify({ product_key: SEAT_PRODUCT_KEY, @@ -1766,11 +1766,11 @@ export class PostHogAPIClient { async upgradeSeat(planKey: string): Promise { try { - const url = new URL(`${this.api.baseUrl}/api/code/seats/me/`); + const url = new URL(`${this.api.baseUrl}/api/seats/me/`); const response = await this.api.fetcher.fetch({ method: "patch", url, - path: "/api/code/seats/me/", + path: "/api/seats/me/", overrides: { body: JSON.stringify({ product_key: SEAT_PRODUCT_KEY, @@ -1786,12 +1786,12 @@ export class PostHogAPIClient { async cancelSeat(): Promise { try { - const url = new URL(`${this.api.baseUrl}/api/code/seats/me/`); + 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/code/seats/me/", + path: "/api/seats/me/", }); } catch (error) { if (this.isFetcherStatusError(error, 204)) { @@ -1803,11 +1803,11 @@ export class PostHogAPIClient { async reactivateSeat(): Promise { try { - const url = new URL(`${this.api.baseUrl}/api/code/seats/me/reactivate/`); + const url = new URL(`${this.api.baseUrl}/api/seats/me/reactivate/`); const response = await this.api.fetcher.fetch({ method: "post", url, - path: "/api/code/seats/me/reactivate/", + path: "/api/seats/me/reactivate/", overrides: { body: JSON.stringify({ product_key: SEAT_PRODUCT_KEY }), }, diff --git a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx b/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx index 6eb816611..8baa8ab20 100644 --- a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx @@ -8,9 +8,9 @@ import { Check, WarningCircle, } from "@phosphor-icons/react"; -import { Badge, Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; +import { Badge, Button, Callout, Dialog, Flex, Spinner, Text } from "@radix-ui/themes"; import codeLogo from "@renderer/assets/images/code.svg"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; interface BillingStepProps { onNext: () => void; @@ -36,6 +36,7 @@ export function BillingStep({ onNext, onBack }: BillingStepProps) { const selectPlan = useOnboardingStore((state) => state.selectPlan); const { isLoading, error, redirectUrl } = useSeat(); const { provisionFreeSeat, upgradeToPro, clearError } = useSeatStore(); + const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); useEffect(() => { if (!selectedPlan) { @@ -50,10 +51,18 @@ export function BillingStep({ onNext, onBack }: BillingStepProps) { const handleContinue = async () => { if (selectedPlan === "free") { await provisionFreeSeat(); + const storeState = useSeatStore.getState(); + if (!storeState.error) { + onNext(); + } } else { - await upgradeToPro(); + setShowUpgradeDialog(true); } + }; + const handleConfirmUpgrade = async () => { + setShowUpgradeDialog(false); + await upgradeToPro(); const storeState = useSeatStore.getState(); if (!storeState.error) { onNext(); @@ -190,6 +199,40 @@ export function BillingStep({ onNext, onBack }: BillingStepProps) { + + + + 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 + + + + + + + + + + ); diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index bda3bc87d..3e762c900 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -2,17 +2,20 @@ 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 { useState } from "react"; import { getPostHogUrl } from "@shared/utils/urls"; export function PlanUsageSettings() { @@ -27,6 +30,7 @@ export function PlanUsageSettings() { } = useSeat(); const { upgradeToPro, cancelSeat, reactivateSeat, clearError } = useSeatStore(); + const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); const formattedActiveUntil = activeUntil ? activeUntil.toLocaleDateString(undefined, { @@ -89,14 +93,14 @@ export function PlanUsageSettings() { name="Free" price="$0" period="/mo" - description="Limited usage" + features={["Limited usage", "Local execution only"]} isCurrent={!isPro} /> setShowUpgradeDialog(true)} disabled={isLoading} style={{ alignSelf: "flex-start" }} > @@ -281,6 +285,42 @@ export function PlanUsageSettings() { )} + + + 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 + + + + + + + + + + ); } @@ -289,7 +329,7 @@ interface PlanCardProps { name: string; price: string; period: string; - description: string; + features: string[]; isCurrent: boolean; resetLabel?: string; action?: React.ReactNode; @@ -299,7 +339,7 @@ function PlanCard({ name, price, period, - description, + features, isCurrent, resetLabel, action, @@ -319,37 +359,49 @@ function PlanCard({ opacity: isCurrent ? 1 : 0.7, }} > - - - {isCurrent ? "CURRENT PLAN" : name.toUpperCase()} - - - - {name} + + + + {isCurrent ? "CURRENT PLAN" : name.toUpperCase()} - - {price} + + + {name} + + + {price} + + {period} + + + + {resetLabel && ( - {period} + {resetLabel} - + )} + + + {features.map((feature) => ( + + + + {feature} + + + ))} - {resetLabel ? ( - - {resetLabel} - - ) : ( - - {description} - - )} {action} From c26fcaff4fb546dedcec5320e270c6b744fa2a85 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 20:59:11 -0700 Subject: [PATCH 08/32] Move getPostHogUrl to renderer utils to fix typecheck --- .../features/auth/components/AuthScreen.tsx | 1 - .../features/billing/stores/seatStore.ts | 2 +- .../onboarding/components/BillingStep.tsx | 27 ++++++++++++++++--- .../features/sessions/service/service.ts | 2 +- .../components/sections/GeneralSettings.tsx | 2 +- .../components/sections/PlanUsageSettings.tsx | 14 +++++++--- apps/code/src/renderer/utils/urls.ts | 8 ++++++ apps/code/src/shared/utils/urls.ts | 9 ------- 8 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 apps/code/src/renderer/utils/urls.ts diff --git a/apps/code/src/renderer/features/auth/components/AuthScreen.tsx b/apps/code/src/renderer/features/auth/components/AuthScreen.tsx index 6c1924f0e..66ebf5067 100644 --- a/apps/code/src/renderer/features/auth/components/AuthScreen.tsx +++ b/apps/code/src/renderer/features/auth/components/AuthScreen.tsx @@ -9,7 +9,6 @@ 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/types/regions"; import type { CloudRegion } from "@shared/types/regions"; import { RegionSelect } from "./RegionSelect"; diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts index 889acefda..b24d30696 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -1,9 +1,9 @@ import { useAuthStore } from "@features/auth/stores/authStore"; -import { getPostHogUrl } from "@shared/utils/urls"; import type { SeatData } from "@shared/types/seat"; import { PLAN_FREE, PLAN_PRO } from "@shared/types/seat"; import { electronStorage } from "@utils/electronStorage"; import { logger } from "@utils/logger"; +import { getPostHogUrl } from "@utils/urls"; import { create } from "zustand"; import { persist } from "zustand/middleware"; diff --git a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx b/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx index 8baa8ab20..c839c0684 100644 --- a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx @@ -8,7 +8,15 @@ import { Check, WarningCircle, } from "@phosphor-icons/react"; -import { Badge, Button, Callout, Dialog, Flex, Spinner, Text } from "@radix-ui/themes"; +import { + Badge, + Button, + Callout, + Dialog, + Flex, + Spinner, + Text, +} from "@radix-ui/themes"; import codeLogo from "@renderer/assets/images/code.svg"; import { useEffect, useState } from "react"; @@ -200,7 +208,10 @@ export function BillingStep({ onNext, onBack }: BillingStepProps) { - + Upgrade to Pro @@ -209,11 +220,19 @@ export function BillingStep({ onNext, onBack }: BillingStepProps) { - + Unlimited token usage - + Local and cloud execution diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index dc3b05b8a..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/utils/urls"; 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/sections/GeneralSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx index d8c1a536a..887a574c3 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx @@ -19,13 +19,13 @@ import { Text, } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc"; -import { getPostHogUrl } from "@shared/utils/urls"; 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"; diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index 3e762c900..3c860e700 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -15,8 +15,8 @@ import { Spinner, Text, } from "@radix-ui/themes"; +import { getPostHogUrl } from "@utils/urls"; import { useState } from "react"; -import { getPostHogUrl } from "@shared/utils/urls"; export function PlanUsageSettings() { const { @@ -294,11 +294,19 @@ export function PlanUsageSettings() { - + Unlimited token usage - + Local and cloud execution 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/utils/urls.ts b/apps/code/src/shared/utils/urls.ts index 82d99bfaf..71b3e29ea 100644 --- a/apps/code/src/shared/utils/urls.ts +++ b/apps/code/src/shared/utils/urls.ts @@ -1,4 +1,3 @@ -import { useAuthStore } from "@features/auth/stores/authStore"; import type { CloudRegion } from "@shared/types/regions"; export function getCloudUrlFromRegion(region: CloudRegion): string { @@ -11,11 +10,3 @@ export function getCloudUrlFromRegion(region: CloudRegion): string { return "http://localhost:8010"; } } - -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}`}`; -} From 7316ef4b163f9f79db0d7f7318ba1b4e5d20ecf4 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 21:41:43 -0700 Subject: [PATCH 09/32] plans --- .../onboarding/components/BillingStep.tsx | 10 ++++++++++ .../components/sections/PlanUsageSettings.tsx | 20 +++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx b/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx index c839c0684..ae2ea4e2e 100644 --- a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx @@ -32,11 +32,13 @@ interface PlanFeature { const FREE_FEATURES: PlanFeature[] = [ { text: "Limited usage" }, { text: "Local execution only" }, + { text: "All Claude and Codex models" }, ]; const PRO_FEATURES: PlanFeature[] = [ { text: "Unlimited usage*" }, { text: "Local and cloud execution" }, + { text: "All Claude and Codex models" }, ]; export function BillingStep({ onNext, onBack }: BillingStepProps) { @@ -235,6 +237,14 @@ export function BillingStep({ onNext, onBack }: BillingStepProps) { /> Local and cloud execution + + + All Claude and Codex models + diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index 3c860e700..babd4f595 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -93,14 +93,22 @@ export function PlanUsageSettings() { name="Free" price="$0" period="/mo" - features={["Limited usage", "Local execution only"]} + features={[ + "Limited usage", + "Local execution only", + "All Claude and Codex models", + ]} isCurrent={!isPro} /> Local and cloud execution + + + All Claude and Codex models + From 313273a22ed52be859137365df9a4721b5a9db7a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 23:05:29 -0700 Subject: [PATCH 10/32] Auto-provision free seat on auth init if none exists --- apps/code/src/renderer/features/billing/stores/seatStore.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts index b24d30696..5c6be238c 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -124,7 +124,11 @@ export const useSeatStore = create()( set({ isLoading: true, error: null, redirectUrl: null }); try { const client = getClient(); - const seat = await client.getMySeat(); + let seat = await client.getMySeat(); + if (!seat) { + log.info("No seat found, auto-provisioning free plan"); + seat = await client.createSeat(PLAN_FREE); + } set({ seat, isLoading: false }); } catch (error) { handleSeatError(error, set); From 996f5782156ed8049e32b2d076ee069ab26913fb Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Fri, 20 Mar 2026 00:10:15 -0700 Subject: [PATCH 11/32] reset --- apps/code/src/renderer/features/auth/stores/authStore.ts | 2 ++ .../settings/components/sections/AdvancedSettings.tsx | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 9bc940d0c..5c2332855 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -1,4 +1,5 @@ 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/utils/urls"; @@ -299,6 +300,7 @@ export const useAuthStore = create((set, get) => ({ track(ANALYTICS_EVENTS.USER_LOGGED_OUT); sessionResetCallback?.(); useSeatStore.getState().reset(); + useSettingsDialogStore.getState().close(); clearAuthenticatedRendererState({ clearAllQueries: true }); await trpcClient.auth.logout.mutate(); useNavigationStore.getState().navigateToTaskInput(); 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() { From fd487576d16507be952712a9cf57e0e49e68fcf5 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 1 Apr 2026 15:42:54 -0700 Subject: [PATCH 12/32] rebase --- .../main/db/repositories/auth-session-repository.ts | 2 +- apps/code/src/main/services/auth/service.ts | 8 +++----- .../renderer/features/auth/components/AuthScreen.tsx | 1 + .../src/renderer/features/auth/hooks/authClient.ts | 2 +- .../src/renderer/features/auth/hooks/authMutations.ts | 2 +- .../src/renderer/features/auth/stores/authStore.ts | 2 +- .../renderer/features/auth/stores/authUiStateStore.ts | 2 +- .../features/inbox/hooks/useSignalSourceManager.ts | 2 +- .../features/onboarding/components/OrgBillingStep.tsx | 10 +--------- .../features/task-detail/hooks/usePreviewConfig.ts | 2 +- 10 files changed, 12 insertions(+), 21 deletions(-) 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/renderer/features/auth/components/AuthScreen.tsx b/apps/code/src/renderer/features/auth/components/AuthScreen.tsx index 66ebf5067..c7288e387 100644 --- a/apps/code/src/renderer/features/auth/components/AuthScreen.tsx +++ b/apps/code/src/renderer/features/auth/components/AuthScreen.tsx @@ -10,6 +10,7 @@ import codeLogo from "@renderer/assets/images/code.svg"; import logomark from "@renderer/assets/images/logomark.svg"; import { trpcClient } from "@renderer/trpc/client"; 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/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/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 5c2332855..426163c56 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -2,9 +2,9 @@ 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/utils/urls"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; 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"; 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/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/OrgBillingStep.tsx b/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx index 3eb9cd412..f75caf3e1 100644 --- a/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx @@ -3,15 +3,7 @@ import { authKeys, useCurrentUser } from "@features/auth/hooks/authQueries"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { useOrganizations } from "@hooks/useOrganizations"; import { ArrowLeft, ArrowRight, CheckCircle } from "@phosphor-icons/react"; -import { - Badge, - Box, - Button, - Callout, - Flex, - Skeleton, - Text, -} from "@radix-ui/themes"; +import { Box, Button, Callout, Flex, Skeleton, Text } from "@radix-ui/themes"; import codeLogo from "@renderer/assets/images/code.svg"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; 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"; From b20e654ffbb6fbdc499de26ef83d697f00885e19 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 8 Apr 2026 00:38:54 -0700 Subject: [PATCH 13/32] Update useOnboardingFlow.ts --- .../src/renderer/features/onboarding/hooks/useOnboardingFlow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts index e411d5283..8d8fd490a 100644 --- a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts +++ b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts @@ -6,7 +6,7 @@ import { ONBOARDING_STEPS, type OnboardingStep } from "../types"; export function useOnboardingFlow() { const currentStep = useOnboardingStore((state) => state.currentStep); const setCurrentStep = useOnboardingStore((state) => state.setCurrentStep); - const billingEnabled = useFeatureFlag("twig-billing", false); + const billingEnabled = useFeatureFlag("posthog-code-billing", false); // Show billing onboarding steps only when billing is enabled const activeSteps = useMemo(() => { From 7e5d2961a7054bab1143763e0399e31facc5990c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 8 Apr 2026 00:49:58 -0700 Subject: [PATCH 14/32] Gate free seat provisioning on billing feature flag --- .../src/renderer/features/auth/stores/authStore.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 426163c56..061aaecd6 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -6,7 +6,12 @@ import { ANALYTICS_EVENTS } from "@shared/types/analytics"; 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 { + identifyUser, + isFeatureFlagEnabled, + resetUser, + track, +} from "@utils/analytics"; import { logger } from "@utils/logger"; import { queryClient } from "@utils/queryClient"; import { create } from "zustand"; @@ -283,7 +288,10 @@ export const useAuthStore = create((set, get) => ({ completeOnboarding: () => { set({ hasCompletedOnboarding: true }); - if (!useSeatStore.getState().seat) { + if ( + isFeatureFlagEnabled("posthog-code-billing") && + !useSeatStore.getState().seat + ) { useSeatStore.getState().provisionFreeSeat(); } }, From 69b8f8a7deb0592bb817f20a5dfb6cf61419dde6 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 8 Apr 2026 01:06:14 -0700 Subject: [PATCH 15/32] Wire up gateway usage endpoint in Plan & Usage settings --- .../src/main/services/llm-gateway/schemas.ts | 18 ++ .../src/main/services/llm-gateway/service.ts | 29 ++- .../code/src/main/trpc/routers/llm-gateway.ts | 10 +- .../components/sections/PlanUsageSettings.tsx | 182 ++++++++++++------ packages/agent/src/posthog-api.ts | 4 +- packages/agent/src/utils/gateway.ts | 28 ++- 6 files changed, 198 insertions(+), 73 deletions(-) diff --git a/apps/code/src/main/services/llm-gateway/schemas.ts b/apps/code/src/main/services/llm-gateway/schemas.ts index 7b8c1ae3e..b248c5905 100644 --- a/apps/code/src/main/services/llm-gateway/schemas.ts +++ b/apps/code/src/main/services/llm-gateway/schemas.ts @@ -56,3 +56,21 @@ export interface AnthropicErrorResponse { code?: string; }; } + +export const usageBucketSchema = z.object({ + used_usd: z.number(), + limit_usd: z.number(), + remaining_usd: 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 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..beb947f79 100644 --- a/apps/code/src/main/services/llm-gateway/service.ts +++ b/apps/code/src/main/services/llm-gateway/service.ts @@ -1,4 +1,7 @@ -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"; @@ -10,6 +13,7 @@ import type { AnthropicMessagesResponse, LlmMessage, PromptOutput, + UsageOutput, } from "./schemas"; const log = logger.scope("llm-gateway"); @@ -134,4 +138,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 (await response.json()) as UsageOutput; + } } 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/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index babd4f595..dcba75c94 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -15,8 +15,63 @@ import { Spinner, Text, } from "@radix-ui/themes"; +import { trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; import { getPostHogUrl } from "@utils/urls"; -import { useState } from "react"; +import { useEffect, useState } from "react"; + +const log = logger.scope("plan-usage"); + +interface UsageBucket { + used_usd: number; + limit_usd: number; + remaining_usd: number; + resets_in_seconds: number; + exceeded: boolean; +} + +interface UsageData { + sustained: UsageBucket; + burst: UsageBucket; + is_rate_limited: boolean; +} + +function formatUsd(amount: number): string { + return `$${amount.toFixed(2)}`; +} + +function formatResetTime(seconds: number): string { + const days = Math.ceil(seconds / 86400); + if (days === 1) return "1 day"; + return `${days} days`; +} + +function useUsage() { + const [usage, setUsage] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + trpcClient.llmGateway.usage + .query() + .then((data) => { + if (!cancelled) setUsage(data); + }) + .catch((error) => { + log.warn("Failed to fetch usage", error); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + return { usage, isLoading }; +} export function PlanUsageSettings() { const { @@ -31,6 +86,7 @@ export function PlanUsageSettings() { const { upgradeToPro, cancelSeat, reactivateSeat, clearError } = useSeatStore(); const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); + const { usage, isLoading: usageLoading } = useUsage(); const formattedActiveUntil = activeUntil ? activeUntil.toLocaleDateString(undefined, { @@ -181,59 +237,30 @@ export function PlanUsageSettings() { Usage - {isPro ? ( + {usageLoading ? ( - - - Token usage - - - Unlimited - - -
-
- -
- - Unlimited tokens included with Pro (go crazy) - + + + ) : usage ? ( + + + ) : ( - - - Token usage - - - 0% - - - - - 0 tokens used this period + + Unable to load usage data )} @@ -349,6 +367,52 @@ export function PlanUsageSettings() { ); } +interface UsageMeterProps { + label: string; + bucket: UsageBucket; + color?: "red"; +} + +function UsageMeter({ label, bucket, color }: UsageMeterProps) { + const percentage = + bucket.limit_usd > 0 + ? Math.min(100, (bucket.used_usd / bucket.limit_usd) * 100) + : 0; + + const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; + + return ( + + + + {label} + + + {formatUsd(bucket.used_usd)} / {formatUsd(bucket.limit_usd)} + + + + + {bucket.exceeded + ? "Limit exceeded" + : `${formatUsd(bucket.remaining_usd)} remaining \u00b7 resets in ${formatResetTime(bucket.resets_in_seconds)}`} + + + ); +} + interface PlanCardProps { name: string; price: string; 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}`; } From c7df4069d909b44064386a7fe76b122c32e12572 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 8 Apr 2026 01:40:30 -0700 Subject: [PATCH 16/32] remove dead initializeOAuth code --- .../features/auth/stores/authStore.test.ts | 13 ++--- .../features/auth/stores/authStore.ts | 56 +------------------ 2 files changed, 6 insertions(+), 63 deletions(-) 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 9821c9f48..de94e1763 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, @@ -127,8 +125,6 @@ describe("authStore", () => { hasCodeAccess: null, needsScopeReauth: false, }); - mockOnStateChangedSubscribe.mockReturnValue({ unsubscribe: vi.fn() }); - useAuthStore.setState({ cloudRegion: null, staleCloudRegion: null, @@ -146,12 +142,11 @@ describe("authStore", () => { }); }); - 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); }); @@ -170,7 +165,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); @@ -190,7 +185,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 061aaecd6..2c40092b9 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -6,20 +6,13 @@ import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import type { CloudRegion } from "@shared/types/regions"; import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { useNavigationStore } from "@stores/navigationStore"; -import { - identifyUser, - isFeatureFlagEnabled, - resetUser, - track, -} from "@utils/analytics"; +import { identifyUser, resetUser, track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { queryClient } from "@utils/queryClient"; 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; @@ -30,8 +23,6 @@ export function setSessionResetCallback(callback: () => void) { } export function resetAuthStoreModuleStateForTest(): void { - initializePromise = null; - authStateSubscription = null; sessionResetCallback = null; inFlightAuthSync = null; inFlightAuthSyncKey = null; @@ -56,9 +47,7 @@ interface AuthStoreState { 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; @@ -204,22 +193,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, _get) => ({ cloudRegion: null, staleCloudRegion: null, @@ -263,22 +237,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 }); @@ -286,16 +244,6 @@ export const useAuthStore = create((set, get) => ({ useNavigationStore.getState().navigateToTaskInput(); }, - completeOnboarding: () => { - set({ hasCompletedOnboarding: true }); - if ( - isFeatureFlagEnabled("posthog-code-billing") && - !useSeatStore.getState().seat - ) { - useSeatStore.getState().provisionFreeSeat(); - } - }, - selectPlan: (plan: "free" | "pro") => { set({ selectedPlan: plan }); }, From b8352937216a4b721378777ee118a28f4a9719c1 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 8 Apr 2026 01:43:59 -0700 Subject: [PATCH 17/32] Move seat fetching to auth session hook and use async client --- .../features/auth/hooks/useAuthSession.ts | 13 ++ .../features/billing/stores/seatStore.ts | 190 +++++++++--------- .../onboarding/stores/onboardingStore.ts | 30 ++- .../settings/components/SettingsDialog.tsx | 14 +- 4 files changed, 149 insertions(+), 98 deletions(-) diff --git a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts index 93ae5e1ad..25e06e4aa 100644 --- a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts +++ b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts @@ -8,6 +8,7 @@ import { useCurrentUser, } from "@features/auth/hooks/authQueries"; import { useAuthUiStateStore } from "@features/auth/stores/authUiStateStore"; +import { useSeatStore } from "@features/billing/stores/seatStore"; import { trpcClient } from "@renderer/trpc/client"; import { identifyUser, resetUser } from "@utils/analytics"; import { logger } from "@utils/logger"; @@ -80,6 +81,17 @@ function useAuthAnalyticsIdentity( }, [authIdentity, authState.cloudRegion, authState.projectId, currentUser]); } +function useSeatSync(authIdentity: string | null): void { + useEffect(() => { + if (!authIdentity) { + useSeatStore.getState().reset(); + return; + } + + void useSeatStore.getState().fetchSeat(); + }, [authIdentity]); +} + export function useAuthSession() { const authState = useAuthStateValue((state) => state); const client = useOptionalAuthenticatedClient(); @@ -89,6 +101,7 @@ export function useAuthSession() { useAuthSubscriptionSync(); useAuthIdentitySync(authIdentity, authState.cloudRegion); useAuthAnalyticsIdentity(authIdentity, authState, currentUser); + useSeatSync(authIdentity); return { authState, diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts index 5c6be238c..54ba22835 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -1,11 +1,9 @@ -import { useAuthStore } from "@features/auth/stores/authStore"; +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; import type { SeatData } from "@shared/types/seat"; import { PLAN_FREE, PLAN_PRO } from "@shared/types/seat"; -import { electronStorage } from "@utils/electronStorage"; import { logger } from "@utils/logger"; import { getPostHogUrl } from "@utils/urls"; import { create } from "zustand"; -import { persist } from "zustand/middleware"; const log = logger.scope("seat-store"); @@ -28,8 +26,8 @@ interface SeatStoreActions { type SeatStore = SeatStoreState & SeatStoreActions; -function getClient() { - const client = useAuthStore.getState().client; +async function getClient() { + const client = await getAuthenticatedClient(); if (!client) { throw new Error("Not authenticated"); } @@ -115,94 +113,96 @@ const initialState: SeatStoreState = { redirectUrl: null, }; -export const useSeatStore = create()( - persist( - (set) => ({ - ...initialState, - - fetchSeat: async () => { - set({ isLoading: true, error: null, redirectUrl: null }); - try { - const client = getClient(); - let seat = await client.getMySeat(); - if (!seat) { - 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 () => { - set({ isLoading: true, error: null, redirectUrl: null }); - try { - const client = getClient(); - const existing = await client.getMySeat(); - if (existing) { - set({ seat: existing, isLoading: false }); - return; - } - const seat = await client.createSeat(PLAN_FREE); - set({ seat, isLoading: false }); - } catch (error) { - handleSeatError(error, set); - } - }, - - upgradeToPro: async () => { - set({ isLoading: true, error: null, redirectUrl: null }); - try { - const client = 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 { - const client = 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 { - const client = getClient(); - const seat = await client.reactivateSeat(); - set({ seat, isLoading: false }); - } catch (error) { - handleSeatError(error, set); +export const useSeatStore = create()((set) => ({ + ...initialState, + + fetchSeat: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + try { + const client = await getClient(); + let seat = await client.getMySeat(); + if (!seat) { + 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("[seat] provisionFreeSeat called"); + set({ isLoading: true, error: null, redirectUrl: null }); + try { + const client = await getClient(); + const existing = await client.getMySeat(); + if (existing) { + log.info("[seat] seat already exists on server", { + plan: existing.plan_key, + status: existing.status, + }); + set({ seat: existing, isLoading: false }); + return; + } + log.info("[seat] creating free seat"); + const seat = await client.createSeat(PLAN_FREE); + log.info("[seat] free seat created", { + id: seat.id, + plan: seat.plan_key, + }); + set({ seat, isLoading: false }); + } catch (error) { + log.error("[seat] provisionFreeSeat failed", error); + handleSeatError(error, set); + } + }, + + upgradeToPro: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + try { + const client = await getClient(); + const existing = await client.getMySeat(); + if (existing) { + if (existing.plan_key === PLAN_PRO) { + set({ seat: existing, isLoading: false }); + return; } - }, - - clearError: () => set({ error: null, redirectUrl: null }), - - reset: () => set(initialState), - }), - { - name: "posthog-code-seat", - storage: electronStorage, - partialize: (state) => ({ seat: state.seat }), - }, - ), -); + 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 { + 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 { + 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/onboarding/stores/onboardingStore.ts b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts index 4d165dbd6..05f40508c 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts @@ -1,7 +1,12 @@ +import { useSeatStore } from "@features/billing/stores/seatStore"; +import { isFeatureFlagEnabled } from "@utils/analytics"; +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; @@ -39,7 +44,30 @@ export const useOnboardingStore = create()( ...initialState, setCurrentStep: (step) => set({ currentStep: step }), - completeOnboarding: () => set({ hasCompletedOnboarding: true }), + completeOnboarding: () => { + const billingEnabled = isFeatureFlagEnabled("posthog-code-billing"); + const existingSeat = useSeatStore.getState().seat; + log.info("[seat] completeOnboarding", { + billingEnabled, + hasSeat: !!existingSeat, + seatPlan: existingSeat?.plan_key ?? null, + }); + set({ hasCompletedOnboarding: true }); + + if (!billingEnabled) { + log.info("[seat] skipped — billing flag disabled"); + return; + } + if (existingSeat) { + log.info("[seat] skipped — seat already exists", { + plan: existingSeat.plan_key, + status: existingSeat.status, + }); + return; + } + log.info("[seat] no seat found — provisioning free seat"); + useSeatStore.getState().provisionFreeSeat(); + }, resetOnboarding: () => set({ ...initialState }), resetSelections: () => set({ diff --git a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx index 3a3fb2255..2b3831037 100644 --- a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx +++ b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx @@ -3,6 +3,7 @@ import { type SettingsCategory, useSettingsDialogStore, } from "@features/settings/stores/settingsDialogStore"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { useSeat } from "@hooks/useSeat"; import { ArrowLeft, @@ -24,7 +25,7 @@ import { } from "@phosphor-icons/react"; import { Avatar, Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; -import { type ReactNode, useEffect } from "react"; +import { type ReactNode, useEffect, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { AdvancedSettings } from "./sections/AdvancedSettings"; import { ClaudeCodeSettings } from "./sections/ClaudeCodeSettings"; @@ -119,6 +120,15 @@ export function SettingsDialog() { useSettingsDialogStore(); const { client, isAuthenticated } = useAuthStore(); 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], + ); const { data: user } = useQuery({ queryKey: ["currentUser"], @@ -208,7 +218,7 @@ export function SettingsDialog() {
- {SIDEBAR_ITEMS.map((item) => ( + {sidebarItems.map((item) => ( Date: Mon, 13 Apr 2026 01:33:27 -0700 Subject: [PATCH 18/32] Switch usage display from USD amounts to percent --- .../src/main/services/llm-gateway/schemas.ts | 4 +--- .../components/sections/PlanUsageSettings.tsx | 17 ++++------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/apps/code/src/main/services/llm-gateway/schemas.ts b/apps/code/src/main/services/llm-gateway/schemas.ts index b248c5905..59570e25e 100644 --- a/apps/code/src/main/services/llm-gateway/schemas.ts +++ b/apps/code/src/main/services/llm-gateway/schemas.ts @@ -58,9 +58,7 @@ export interface AnthropicErrorResponse { } export const usageBucketSchema = z.object({ - used_usd: z.number(), - limit_usd: z.number(), - remaining_usd: z.number(), + used_percent: z.number(), resets_in_seconds: z.number(), exceeded: z.boolean(), }); diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index dcba75c94..f2915b8a1 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -23,9 +23,7 @@ import { useEffect, useState } from "react"; const log = logger.scope("plan-usage"); interface UsageBucket { - used_usd: number; - limit_usd: number; - remaining_usd: number; + used_percent: number; resets_in_seconds: number; exceeded: boolean; } @@ -36,10 +34,6 @@ interface UsageData { is_rate_limited: boolean; } -function formatUsd(amount: number): string { - return `$${amount.toFixed(2)}`; -} - function formatResetTime(seconds: number): string { const days = Math.ceil(seconds / 86400); if (days === 1) return "1 day"; @@ -374,10 +368,7 @@ interface UsageMeterProps { } function UsageMeter({ label, bucket, color }: UsageMeterProps) { - const percentage = - bucket.limit_usd > 0 - ? Math.min(100, (bucket.used_usd / bucket.limit_usd) * 100) - : 0; + const percentage = bucket.used_percent; const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; @@ -396,7 +387,7 @@ function UsageMeter({ label, bucket, color }: UsageMeterProps) { {label} - {formatUsd(bucket.used_usd)} / {formatUsd(bucket.limit_usd)} + {Math.round(percentage)}% {bucket.exceeded ? "Limit exceeded" - : `${formatUsd(bucket.remaining_usd)} remaining \u00b7 resets in ${formatResetTime(bucket.resets_in_seconds)}`} + : `Resets in ${formatResetTime(bucket.resets_in_seconds)}`} ); From 4f2ceb0c3652a98eb3b6fd8ac132460702738e3a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 13 Apr 2026 01:36:15 -0700 Subject: [PATCH 19/32] lint --- .../renderer/features/sessions/hooks/useSessionConnection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"; From 2249486dd07290b95fdbb2a3d7544761dc5a7a70 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 13 Apr 2026 13:09:21 -0700 Subject: [PATCH 20/32] Fix broken imports in GitHubConnectionBanner --- .../features/inbox/components/list/GitHubConnectionBanner.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"; From 60d6e15fc7ee1ce9db96b1e910c18648de5af581 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 11:16:56 -0700 Subject: [PATCH 21/32] Gate seat auto-provisioning behind posthog-code-billing flag --- .../features/auth/hooks/useAuthSession.ts | 16 +- .../features/billing/stores/seatStore.ts | 6 +- .../onboarding/components/BillingStep.tsx | 360 ------------------ .../onboarding/components/OnboardingFlow.tsx | 28 -- .../onboarding/components/OrgBillingStep.tsx | 254 ------------ .../onboarding/hooks/useOnboardingFlow.ts | 20 +- .../onboarding/stores/onboardingStore.ts | 37 +- .../src/renderer/features/onboarding/types.ts | 4 - .../src/renderer/hooks/useOrganizations.ts | 6 +- 9 files changed, 18 insertions(+), 713 deletions(-) delete mode 100644 apps/code/src/renderer/features/onboarding/components/BillingStep.tsx delete mode 100644 apps/code/src/renderer/features/onboarding/components/OrgBillingStep.tsx diff --git a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts index 25e06e4aa..cda49d3a2 100644 --- a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts +++ b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts @@ -9,6 +9,7 @@ import { } 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"; @@ -81,15 +82,20 @@ function useAuthAnalyticsIdentity( }, [authIdentity, authState.cloudRegion, authState.projectId, currentUser]); } -function useSeatSync(authIdentity: string | null): void { +function useSeatSync( + authIdentity: string | null, + billingEnabled: boolean, +): void { useEffect(() => { if (!authIdentity) { useSeatStore.getState().reset(); return; } - void useSeatStore.getState().fetchSeat(); - }, [authIdentity]); + void useSeatStore.getState().fetchSeat({ + autoProvision: billingEnabled, + }); + }, [authIdentity, billingEnabled]); } export function useAuthSession() { @@ -98,10 +104,12 @@ export function useAuthSession() { 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); + useSeatSync(authIdentity, billingEnabled); return { authState, diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts index 54ba22835..78cd50d8a 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -15,7 +15,7 @@ interface SeatStoreState { } interface SeatStoreActions { - fetchSeat: () => Promise; + fetchSeat: (options?: { autoProvision?: boolean }) => Promise; provisionFreeSeat: () => Promise; upgradeToPro: () => Promise; cancelSeat: () => Promise; @@ -116,12 +116,12 @@ const initialState: SeatStoreState = { export const useSeatStore = create()((set) => ({ ...initialState, - fetchSeat: async () => { + fetchSeat: async (options?: { autoProvision?: boolean }) => { set({ isLoading: true, error: null, redirectUrl: null }); try { const client = await getClient(); let seat = await client.getMySeat(); - if (!seat) { + if (!seat && options?.autoProvision) { log.info("No seat found, auto-provisioning free plan"); seat = await client.createSeat(PLAN_FREE); } 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 ae2ea4e2e..000000000 --- a/apps/code/src/renderer/features/onboarding/components/BillingStep.tsx +++ /dev/null @@ -1,360 +0,0 @@ -import { useSeatStore } from "@features/billing/stores/seatStore"; -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { useSeat } from "@hooks/useSeat"; -import { - ArrowLeft, - ArrowRight, - ArrowSquareOut, - Check, - WarningCircle, -} from "@phosphor-icons/react"; -import { - Badge, - Button, - Callout, - Dialog, - Flex, - Spinner, - Text, -} from "@radix-ui/themes"; -import codeLogo from "@renderer/assets/images/code.svg"; -import { useEffect, useState } from "react"; - -interface BillingStepProps { - onNext: () => void; - onBack: () => void; -} - -interface PlanFeature { - text: string; -} - -const FREE_FEATURES: PlanFeature[] = [ - { text: "Limited usage" }, - { text: "Local execution only" }, - { text: "All Claude and Codex models" }, -]; - -const PRO_FEATURES: PlanFeature[] = [ - { text: "Unlimited usage*" }, - { text: "Local and cloud execution" }, - { text: "All Claude and Codex models" }, -]; - -export function BillingStep({ onNext, onBack }: BillingStepProps) { - const selectedPlan = useOnboardingStore((state) => state.selectedPlan); - const selectPlan = useOnboardingStore((state) => state.selectPlan); - const { isLoading, error, redirectUrl } = useSeat(); - const { provisionFreeSeat, upgradeToPro, clearError } = useSeatStore(); - const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); - - useEffect(() => { - if (!selectedPlan) { - selectPlan("pro"); - } - }, [selectedPlan, selectPlan]); - - useEffect(() => { - return () => clearError(); - }, [clearError]); - - const handleContinue = async () => { - if (selectedPlan === "free") { - await provisionFreeSeat(); - const storeState = useSeatStore.getState(); - if (!storeState.error) { - onNext(); - } - } else { - setShowUpgradeDialog(true); - } - }; - - const handleConfirmUpgrade = async () => { - setShowUpgradeDialog(false); - await upgradeToPro(); - const storeState = useSeatStore.getState(); - if (!storeState.error) { - onNext(); - } - }; - - return ( - - - PostHog - - - - - Choose your plan - - - {error && !redirectUrl && ( - - - - - {error} - - )} - - {redirectUrl && ( - - - - - - - - Your organization needs an active billing subscription - before you can select a plan. - - - - - - )} - - - selectPlan("free")} - /> - - 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. - - - - - - - - - - - - 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 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 31011e790..c9f089161 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx @@ -7,9 +7,7 @@ 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 { SignalsStep } from "./SignalsStep"; import { StepIndicator } from "./StepIndicator"; import { TutorialStep } from "./TutorialStep"; @@ -98,32 +96,6 @@ export function OnboardingFlow() { )} - {currentStep === "org-billing" && ( - - - - )} - - {currentStep === "billing" && ( - - - - )} - {currentStep === "git-integration" && ( void; - onBack: () => void; -} - -export function OrgBillingStep({ onNext, onBack }: OrgBillingStepProps) { - const selectedOrgId = useOnboardingStore((state) => state.selectedOrgId); - const selectOrg = useOnboardingStore((state) => state.selectOrg); - const client = useAuthenticatedClient(); - const { data: currentUser } = useCurrentUser({ client }); - const queryClient = useQueryClient(); - const switchOrganizationMutation = useMutation({ - mutationFn: async (orgId: string) => { - await client.switchOrganization(orgId); - await queryClient.invalidateQueries({ - queryKey: authKeys.currentUsers(), - }); - }, - onError: (err) => { - log.error("Failed to switch organization", err); - }, - }); - - const { orgs, effectiveSelectedOrgId, isLoading, error } = useOrganizations(); - - const currentUserOrgId = currentUser?.organization?.id; - - const handleContinue = async () => { - if (!effectiveSelectedOrgId) return; - - if (effectiveSelectedOrgId !== selectedOrgId) { - selectOrg(effectiveSelectedOrgId); - } - - if (client && effectiveSelectedOrgId !== currentUserOrgId) { - try { - await switchOrganizationMutation.mutateAsync(effectiveSelectedOrgId); - } catch {} - } - - onNext(); - }; - - const handleSelect = (orgId: string) => { - selectOrg(orgId); - }; - - return ( - - - - PostHog - - Choose your organization - - - Select which organization should be billed for your PostHog Code - usage. - - - - {error && ( - - - Failed to load organizations. Please try again later. - - - )} - - - - {isLoading ? ( - - - - - - - - - - - ) : ( - - - {orgs.map((org) => ( - handleSelect(org.id)} - /> - ))} - - - )} - - - - - - - - - - ); -} - -interface OrgCardProps { - name: string; - isSelected: boolean; - onSelect: () => void; -} - -function OrgCard({ name, isSelected, onSelect }: OrgCardProps) { - return ( - - - - {name} - - - - - {isSelected && ( - - )} - - - ); -} diff --git a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts index 8d8fd490a..a0b0bfbbb 100644 --- a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts +++ b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts @@ -1,29 +1,11 @@ import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; -import { useEffect, useMemo } from "react"; import { ONBOARDING_STEPS, type OnboardingStep } from "../types"; export function useOnboardingFlow() { const currentStep = useOnboardingStore((state) => state.currentStep); const setCurrentStep = useOnboardingStore((state) => state.setCurrentStep); - const billingEnabled = useFeatureFlag("posthog-code-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 05f40508c..d4e985f70 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts @@ -1,5 +1,3 @@ -import { useSeatStore } from "@features/billing/stores/seatStore"; -import { isFeatureFlagEnabled } from "@utils/analytics"; import { logger } from "@utils/logger"; import { create } from "zustand"; import { persist } from "zustand/middleware"; @@ -11,8 +9,6 @@ interface OnboardingStoreState { currentStep: OnboardingStep; hasCompletedOnboarding: boolean; isConnectingGithub: boolean; - selectedPlan: "free" | "pro" | null; - selectedOrgId: string | null; selectedProjectId: number | null; } @@ -22,8 +18,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; } @@ -33,8 +27,6 @@ const initialState: OnboardingStoreState = { currentStep: "welcome", hasCompletedOnboarding: false, isConnectingGithub: false, - selectedPlan: null, - selectedOrgId: null, selectedProjectId: null, }; @@ -45,41 +37,17 @@ export const useOnboardingStore = create()( setCurrentStep: (step) => set({ currentStep: step }), completeOnboarding: () => { - const billingEnabled = isFeatureFlagEnabled("posthog-code-billing"); - const existingSeat = useSeatStore.getState().seat; - log.info("[seat] completeOnboarding", { - billingEnabled, - hasSeat: !!existingSeat, - seatPlan: existingSeat?.plan_key ?? null, - }); + log.info("completeOnboarding"); set({ hasCompletedOnboarding: true }); - - if (!billingEnabled) { - log.info("[seat] skipped — billing flag disabled"); - return; - } - if (existingSeat) { - log.info("[seat] skipped — seat already exists", { - plan: existingSeat.plan_key, - status: existingSeat.status, - }); - return; - } - log.info("[seat] no seat found — provisioning free seat"); - useSeatStore.getState().provisionFreeSeat(); }, 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 }), }), { @@ -87,9 +55,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 d23ceb877..1f6147bb0 100644 --- a/apps/code/src/renderer/features/onboarding/types.ts +++ b/apps/code/src/renderer/features/onboarding/types.ts @@ -1,15 +1,11 @@ export type OnboardingStep = | "welcome" - | "billing" - | "org-billing" | "git-integration" | "signals" | "tutorial"; export const ONBOARDING_STEPS: OnboardingStep[] = [ "welcome", - "org-billing", - "billing", "git-integration", "signals", "tutorial", diff --git a/apps/code/src/renderer/hooks/useOrganizations.ts b/apps/code/src/renderer/hooks/useOrganizations.ts index 7e22763c2..544e4c76b 100644 --- a/apps/code/src/renderer/hooks/useOrganizations.ts +++ b/apps/code/src/renderer/hooks/useOrganizations.ts @@ -1,6 +1,5 @@ import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useCurrentUser } from "@features/auth/hooks/authQueries"; -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { useMemo } from "react"; @@ -28,7 +27,6 @@ async function fetchOrgs(client: PostHogAPIClient): Promise { } export function useOrganizations() { - const selectedOrgId = useOnboardingStore((state) => state.selectedOrgId); const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); @@ -43,17 +41,15 @@ export function useOrganizations() { ); const effectiveSelectedOrgId = useMemo(() => { - if (selectedOrgId) return selectedOrgId; if (!orgs?.length) return null; - // Default to the user's currently active org in PostHog const userCurrentOrgId = currentUser?.organization?.id; if (userCurrentOrgId && orgs.some((org) => org.id === userCurrentOrgId)) { return userCurrentOrgId; } return orgs[0].id; - }, [currentUser?.organization?.id, orgs, selectedOrgId]); + }, [currentUser?.organization?.id, orgs]); const sortedOrgs = useMemo(() => { return [...(orgs ?? [])].sort((a, b) => a.name.localeCompare(b.name)); From 40bed9b6bdff99040085be3510880d86b4509a80 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 11:21:18 -0700 Subject: [PATCH 22/32] stop prefixing v in version in the update banner --- .../src/renderer/features/sidebar/components/UpdateBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 55390f1cdb50b87e232f73247a17209087569eab Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 13:36:49 -0700 Subject: [PATCH 23/32] Restore org picker step in onboarding flow --- .../onboarding/components/OnboardingFlow.tsx | 14 + .../onboarding/components/OrgStep.tsx | 255 ++++++++++++++++++ .../onboarding/stores/onboardingStore.ts | 5 + .../src/renderer/features/onboarding/types.ts | 2 + .../src/renderer/hooks/useOrganizations.ts | 5 +- 5 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 apps/code/src/renderer/features/onboarding/components/OrgStep.tsx diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx index c9f089161..aac5d8558 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx @@ -8,6 +8,7 @@ import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; import { useOnboardingFlow } from "../hooks/useOnboardingFlow"; import { GitIntegrationStep } from "./GitIntegrationStep"; +import { OrgStep } from "./OrgStep"; import { SignalsStep } from "./SignalsStep"; import { StepIndicator } from "./StepIndicator"; import { TutorialStep } from "./TutorialStep"; @@ -96,6 +97,19 @@ export function OnboardingFlow() { )} + {currentStep === "org" && ( + + + + )} + {currentStep === "git-integration" && ( void; + onBack: () => void; +} + +export function OrgStep({ onNext, onBack }: OrgStepProps) { + const selectedOrgId = useOnboardingStore((state) => state.selectedOrgId); + const selectOrg = useOnboardingStore((state) => state.selectOrg); + const client = useAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ client }); + const queryClient = useQueryClient(); + const switchOrganizationMutation = useMutation({ + mutationFn: async (orgId: string) => { + await client.switchOrganization(orgId); + await queryClient.invalidateQueries({ + queryKey: authKeys.currentUsers(), + }); + }, + onError: (err) => { + log.error("Failed to switch organization", err); + }, + }); + + const { orgs, effectiveSelectedOrgId, isLoading, error } = useOrganizations(); + + const currentUserOrgId = currentUser?.organization?.id; + + const handleContinue = async () => { + if (!effectiveSelectedOrgId) return; + + if (effectiveSelectedOrgId !== selectedOrgId) { + selectOrg(effectiveSelectedOrgId); + } + + if (client && effectiveSelectedOrgId !== currentUserOrgId) { + try { + await switchOrganizationMutation.mutateAsync(effectiveSelectedOrgId); + } catch { + // Error handled by onError callback + } + } + + onNext(); + }; + + const handleSelect = (orgId: string) => { + selectOrg(orgId); + }; + + return ( + + + + PostHog + + Choose your organization + + + Select which PostHog organization to use with PostHog Code. + + + + {error && ( + + + Failed to load organizations. Please try again later. + + + )} + + + + {isLoading ? ( + + + + + + + + + + + ) : ( + + + {orgs.map((org) => ( + handleSelect(org.id)} + /> + ))} + + + )} + + + + + + + + + + ); +} + +interface OrgCardProps { + name: string; + isSelected: boolean; + onSelect: () => void; +} + +function OrgCard({ name, isSelected, onSelect }: OrgCardProps) { + return ( + + + + {name} + + + + + {isSelected && ( + + )} + + + ); +} diff --git a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts index d4e985f70..d5487c96c 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts @@ -9,6 +9,7 @@ interface OnboardingStoreState { currentStep: OnboardingStep; hasCompletedOnboarding: boolean; isConnectingGithub: boolean; + selectedOrgId: string | null; selectedProjectId: number | null; } @@ -18,6 +19,7 @@ interface OnboardingStoreActions { resetOnboarding: () => void; resetSelections: () => void; setConnectingGithub: (isConnecting: boolean) => void; + selectOrg: (orgId: string) => void; selectProjectId: (projectId: number | null) => void; } @@ -27,6 +29,7 @@ const initialState: OnboardingStoreState = { currentStep: "welcome", hasCompletedOnboarding: false, isConnectingGithub: false, + selectedOrgId: null, selectedProjectId: null, }; @@ -45,9 +48,11 @@ export const useOnboardingStore = create()( set({ currentStep: "welcome", isConnectingGithub: false, + selectedOrgId: null, selectedProjectId: null, }), setConnectingGithub: (isConnectingGithub) => set({ isConnectingGithub }), + selectOrg: (orgId) => set({ selectedOrgId: orgId }), selectProjectId: (selectedProjectId) => set({ selectedProjectId }), }), { diff --git a/apps/code/src/renderer/features/onboarding/types.ts b/apps/code/src/renderer/features/onboarding/types.ts index 1f6147bb0..759af6b79 100644 --- a/apps/code/src/renderer/features/onboarding/types.ts +++ b/apps/code/src/renderer/features/onboarding/types.ts @@ -1,11 +1,13 @@ export type OnboardingStep = | "welcome" + | "org" | "git-integration" | "signals" | "tutorial"; export const ONBOARDING_STEPS: OnboardingStep[] = [ "welcome", + "org", "git-integration", "signals", "tutorial", diff --git a/apps/code/src/renderer/hooks/useOrganizations.ts b/apps/code/src/renderer/hooks/useOrganizations.ts index 544e4c76b..6369fb1d5 100644 --- a/apps/code/src/renderer/hooks/useOrganizations.ts +++ b/apps/code/src/renderer/hooks/useOrganizations.ts @@ -1,5 +1,6 @@ import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useCurrentUser } from "@features/auth/hooks/authQueries"; +import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { useMemo } from "react"; @@ -27,6 +28,7 @@ async function fetchOrgs(client: PostHogAPIClient): Promise { } export function useOrganizations() { + const selectedOrgId = useOnboardingStore((state) => state.selectedOrgId); const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); @@ -41,6 +43,7 @@ export function useOrganizations() { ); const effectiveSelectedOrgId = useMemo(() => { + if (selectedOrgId) return selectedOrgId; if (!orgs?.length) return null; const userCurrentOrgId = currentUser?.organization?.id; @@ -49,7 +52,7 @@ export function useOrganizations() { } return orgs[0].id; - }, [currentUser?.organization?.id, orgs]); + }, [currentUser?.organization?.id, orgs, selectedOrgId]); const sortedOrgs = useMemo(() => { return [...(orgs ?? [])].sort((a, b) => a.name.localeCompare(b.name)); From 7a357fb923e194f86f04b5021efde196c9986259 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 13:39:27 -0700 Subject: [PATCH 24/32] Add project picker to org onboarding step --- .../onboarding/components/OrgStep.tsx | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx b/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx index e403170d0..b2e79d98f 100644 --- a/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx @@ -1,6 +1,11 @@ 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 { Box, Button, Callout, Flex, Skeleton, Text } from "@radix-ui/themes"; @@ -8,6 +13,8 @@ import codeLogo from "@renderer/assets/images/code.svg"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { AnimatePresence, motion } from "framer-motion"; +import { useMemo } from "react"; +import { ProjectSelect } from "./ProjectSelect"; const log = logger.scope("org-step"); @@ -19,9 +26,17 @@ interface OrgStepProps { 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); @@ -37,6 +52,19 @@ export function OrgStep({ onNext, onBack }: OrgStepProps) { 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 selectedProject = useMemo( + () => projects.find((p) => p.id === selectedProjectId), + [projects, selectedProjectId], + ); const handleContinue = async () => { if (!effectiveSelectedOrgId) return; @@ -45,7 +73,7 @@ export function OrgStep({ onNext, onBack }: OrgStepProps) { selectOrg(effectiveSelectedOrgId); } - if (client && effectiveSelectedOrgId !== currentUserOrgId) { + if (client && hasOrgChanged) { try { await switchOrganizationMutation.mutateAsync(effectiveSelectedOrgId); } catch { @@ -58,6 +86,7 @@ export function OrgStep({ onNext, onBack }: OrgStepProps) { const handleSelect = (orgId: string) => { selectOrg(orgId); + setSelectedProjectId(null); }; return ( @@ -93,7 +122,8 @@ export function OrgStep({ onNext, onBack }: OrgStepProps) { Choose your organization - Select which PostHog organization to use with PostHog Code. + Select which PostHog organization and project to use with PostHog + Code. @@ -168,6 +198,28 @@ export function OrgStep({ onNext, onBack }: OrgStepProps) { )} + + {!isLoading && !hasOrgChanged && selectedProject && ( + + + Project + + ({ + id: p.id, + name: p.name, + }))} + onProjectChange={setSelectedProjectId} + disabled={projectsLoading} + /> + + )} From 9056ba6ca885ef37a028d7d2bb465c6dd073df80 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 13:44:10 -0700 Subject: [PATCH 25/32] Fix org step spacing and project dropdown theming --- .../onboarding/components/OrgStep.tsx | 163 ++++++++++-------- 1 file changed, 92 insertions(+), 71 deletions(-) diff --git a/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx b/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx index b2e79d98f..f1069c229 100644 --- a/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx @@ -8,13 +8,20 @@ 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 { Box, Button, Callout, Flex, Skeleton, Text } from "@radix-ui/themes"; +import { + Box, + Button, + Callout, + Flex, + Select, + Skeleton, + Text, +} from "@radix-ui/themes"; import codeLogo from "@renderer/assets/images/code.svg"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { AnimatePresence, motion } from "framer-motion"; import { useMemo } from "react"; -import { ProjectSelect } from "./ProjectSelect"; const log = logger.scope("org-step"); @@ -61,11 +68,6 @@ export function OrgStep({ onNext, onBack }: OrgStepProps) { return currentProjectId ?? projects[0]?.id ?? null; }, [manuallySelectedProjectId, currentProjectId, projects]); - const selectedProject = useMemo( - () => projects.find((p) => p.id === selectedProjectId), - [projects, selectedProjectId], - ); - const handleContinue = async () => { if (!effectiveSelectedOrgId) return; @@ -84,7 +86,7 @@ export function OrgStep({ onNext, onBack }: OrgStepProps) { onNext(); }; - const handleSelect = (orgId: string) => { + const handleSelectOrg = (orgId: string) => { selectOrg(orgId); setSelectedProjectId(null); }; @@ -101,7 +103,7 @@ export function OrgStep({ onNext, onBack }: OrgStepProps) { paddingBottom: 40, }} > - + PostHog - - {isLoading ? ( - - - - - - - + + Organization + + + {isLoading ? ( + + + + > + + + + + - - - ) : ( - - - {orgs.map((org) => ( - handleSelect(org.id)} - /> - ))} - - - )} - + + ) : ( + + + {orgs.map((org) => ( + handleSelectOrg(org.id)} + /> + ))} + + + )} + + - {!isLoading && !hasOrgChanged && selectedProject && ( - + {!isLoading && !hasOrgChanged && projects.length > 0 && ( + Project - ({ - id: p.id, - name: p.name, - }))} - onProjectChange={setSelectedProjectId} + setSelectedProjectId(Number(value))} + size="2" disabled={projectsLoading} - /> + > + + + {projects.map((project) => ( + + {project.name} + + ))} + + )} From 5178ced4eddeb3107f71259aad203a0a323b202f Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 14:11:56 -0700 Subject: [PATCH 26/32] Update free plan to show local and cloud execution --- .../features/settings/components/sections/PlanUsageSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index f2915b8a1..561c6e002 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -145,7 +145,7 @@ export function PlanUsageSettings() { period="/mo" features={[ "Limited usage", - "Local execution only", + "Local and cloud execution", "All Claude and Codex models", ]} isCurrent={!isPro} From 7a0b551ac61613287b4b079fe73d8a1770ccb7b7 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 14:20:17 -0700 Subject: [PATCH 27/32] Show usage percentages to 2 decimal places --- .../features/settings/components/sections/PlanUsageSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index 561c6e002..d40bd38e8 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -387,7 +387,7 @@ function UsageMeter({ label, bucket, color }: UsageMeterProps) { {label} - {Math.round(percentage)}% + {percentage.toFixed(2)}% Date: Thu, 16 Apr 2026 14:28:09 -0700 Subject: [PATCH 28/32] Add tooltip on unlimited usage asterisk --- .../components/sections/PlanUsageSettings.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index d40bd38e8..6107b2687 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -15,6 +15,7 @@ import { Spinner, Text, } from "@radix-ui/themes"; +import { Tooltip } from "@renderer/components/ui/Tooltip"; import { trpcClient } from "@renderer/trpc/client"; import { logger } from "@utils/logger"; import { getPostHogUrl } from "@utils/urls"; @@ -476,7 +477,16 @@ function PlanCard({ style={{ color: "var(--accent-9)", flexShrink: 0 }} /> - {feature} + {feature.endsWith("*") ? ( + <> + {feature.slice(0, -1)} + + * + + + ) : ( + feature + )} ))} From 3073a6f04aafa4dc76b34cd83246fe9cd554053b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 14:39:49 -0700 Subject: [PATCH 29/32] Gate seat provisioning behind billing flag and send user_distinct_id --- apps/code/src/renderer/api/posthogClient.ts | 8 +++++++- .../src/renderer/features/auth/hooks/useAuthSession.ts | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index aec5f9395..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, @@ -1746,6 +1746,11 @@ export class PostHogAPIClient { 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", @@ -1755,6 +1760,7 @@ export class PostHogAPIClient { body: JSON.stringify({ product_key: SEAT_PRODUCT_KEY, plan_key: planKey, + user_distinct_id: distinctId, }), }, }); diff --git a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts index cda49d3a2..56673fcbd 100644 --- a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts +++ b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts @@ -87,13 +87,13 @@ function useSeatSync( billingEnabled: boolean, ): void { useEffect(() => { - if (!authIdentity) { + if (!authIdentity || !billingEnabled) { useSeatStore.getState().reset(); return; } void useSeatStore.getState().fetchSeat({ - autoProvision: billingEnabled, + autoProvision: true, }); }, [authIdentity, billingEnabled]); } From 8cb0af1d2331e4d79a22906b1650da0ccd8a2f59 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 15:53:26 -0700 Subject: [PATCH 30/32] Fix billing review issues across stores and settings --- .../src/main/services/llm-gateway/schemas.ts | 1 + .../src/main/services/llm-gateway/service.ts | 17 ++--- .../features/auth/stores/authStore.ts | 2 +- .../features/billing/stores/seatStore.ts | 70 ++++--------------- .../onboarding/components/OrgStep.tsx | 11 +++ .../settings/components/SettingsDialog.tsx | 21 +++--- .../components/sections/PlanUsageSettings.tsx | 48 ++++--------- 7 files changed, 58 insertions(+), 112 deletions(-) diff --git a/apps/code/src/main/services/llm-gateway/schemas.ts b/apps/code/src/main/services/llm-gateway/schemas.ts index 59570e25e..8268e9067 100644 --- a/apps/code/src/main/services/llm-gateway/schemas.ts +++ b/apps/code/src/main/services/llm-gateway/schemas.ts @@ -71,4 +71,5 @@ export const usageOutput = z.object({ 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 beb947f79..00e04d766 100644 --- a/apps/code/src/main/services/llm-gateway/service.ts +++ b/apps/code/src/main/services/llm-gateway/service.ts @@ -7,13 +7,14 @@ 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, - UsageOutput, +import { + type AnthropicErrorResponse, + type AnthropicMessagesRequest, + type AnthropicMessagesResponse, + type LlmMessage, + type PromptOutput, + type UsageOutput, + usageOutput, } from "./schemas"; const log = logger.scope("llm-gateway"); @@ -159,6 +160,6 @@ export class LlmGatewayService { ); } - return (await response.json()) as UsageOutput; + return usageOutput.parse(await response.json()); } } diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 2c40092b9..e0a5391ca 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -193,7 +193,7 @@ async function syncAuthState(): Promise { await inFlightAuthSync; } -export const useAuthStore = create((set, _get) => ({ +export const useAuthStore = create((set) => ({ cloudRegion: null, staleCloudRegion: null, diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts index 78cd50d8a..d681a4939 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -1,4 +1,8 @@ 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 { logger } from "@utils/logger"; @@ -34,25 +38,6 @@ async function getClient() { return client; } -function parseFetcherError( - error: Error, -): { status: number; body: Record } | 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: {} }; - } -} - -function getBillingUrl(): string { - return getPostHogUrl("/organization/billing"); -} - function handleSeatError( error: unknown, set: (state: Partial) => void, @@ -63,43 +48,18 @@ function handleSeatError( return; } - const billingUrl = getBillingUrl(); - - if ( - "redirectUrl" in error && - typeof (error as { redirectUrl: unknown }).redirectUrl === "string" - ) { + if (error instanceof SeatSubscriptionRequiredError) { set({ isLoading: false, error: "Billing subscription required", - redirectUrl: billingUrl, + redirectUrl: getPostHogUrl("/organization/billing"), }); return; } - const parsed = parseFetcherError(error); - if (parsed) { - if (parsed.status === 400 && typeof parsed.body.redirect_url === "string") { - set({ - isLoading: false, - error: - typeof parsed.body.error === "string" - ? parsed.body.error - : "Billing subscription required", - redirectUrl: billingUrl, - }); - return; - } - if (parsed.status === 402) { - set({ - isLoading: false, - error: - typeof parsed.body.error === "string" - ? parsed.body.error - : "Payment failed", - }); - return; - } + if (error instanceof SeatPaymentFailedError) { + set({ isLoading: false, error: error.message }); + return; } log.error("Seat operation failed", error); @@ -132,28 +92,24 @@ export const useSeatStore = create()((set) => ({ }, provisionFreeSeat: async () => { - log.info("[seat] provisionFreeSeat called"); + log.info("Provisioning free seat"); set({ isLoading: true, error: null, redirectUrl: null }); try { const client = await getClient(); const existing = await client.getMySeat(); if (existing) { - log.info("[seat] seat already exists on server", { + log.info("Seat already exists on server", { plan: existing.plan_key, status: existing.status, }); set({ seat: existing, isLoading: false }); return; } - log.info("[seat] creating free seat"); const seat = await client.createSeat(PLAN_FREE); - log.info("[seat] free seat created", { - id: seat.id, - plan: seat.plan_key, - }); + log.info("Free seat created", { id: seat.id, plan: seat.plan_key }); set({ seat, isLoading: false }); } catch (error) { - log.error("[seat] provisionFreeSeat failed", error); + log.error("provisionFreeSeat failed", error); handleSeatError(error, set); } }, diff --git a/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx b/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx index f1069c229..6d581e483 100644 --- a/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OrgStep.tsx @@ -18,6 +18,7 @@ import { 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"; @@ -83,6 +84,16 @@ export function OrgStep({ onNext, onBack }: OrgStepProps) { } } + if ( + !hasOrgChanged && + selectedProjectId && + selectedProjectId !== currentProjectId + ) { + await trpcClient.auth.selectProject.mutate({ + projectId: selectedProjectId, + }); + } + onNext(); }; diff --git a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx index 2b3831037..af1a3969c 100644 --- a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx +++ b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx @@ -1,3 +1,8 @@ +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, @@ -24,7 +29,6 @@ import { Wrench, } from "@phosphor-icons/react"; import { Avatar, Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; -import { useQuery } from "@tanstack/react-query"; import { type ReactNode, useEffect, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { AdvancedSettings } from "./sections/AdvancedSettings"; @@ -118,7 +122,11 @@ const CATEGORY_COMPONENTS: Record = { export function SettingsDialog() { const { isOpen, activeCategory, close, setCategory } = useSettingsDialogStore(); - const { client, isAuthenticated } = useAuthStore(); + 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"); @@ -130,15 +138,6 @@ export function SettingsDialog() { [billingEnabled], ); - const { data: user } = useQuery({ - queryKey: ["currentUser"], - queryFn: async () => { - if (!client) return null; - return await client.getCurrentUser(); - }, - enabled: !!client && isAuthenticated, - }); - useHotkeys("escape", close, { enabled: isOpen, enableOnContentEditable: true, diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx index 6107b2687..438dfe384 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx @@ -16,12 +16,10 @@ import { Text, } from "@radix-ui/themes"; import { Tooltip } from "@renderer/components/ui/Tooltip"; -import { trpcClient } from "@renderer/trpc/client"; -import { logger } from "@utils/logger"; +import { useTRPC } from "@renderer/trpc"; +import { useQuery } from "@tanstack/react-query"; import { getPostHogUrl } from "@utils/urls"; -import { useEffect, useState } from "react"; - -const log = logger.scope("plan-usage"); +import { useState } from "react"; interface UsageBucket { used_percent: number; @@ -29,43 +27,23 @@ interface UsageBucket { exceeded: boolean; } -interface UsageData { - sustained: UsageBucket; - burst: UsageBucket; - is_rate_limited: 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 [usage, setUsage] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - - trpcClient.llmGateway.usage - .query() - .then((data) => { - if (!cancelled) setUsage(data); - }) - .catch((error) => { - log.warn("Failed to fetch usage", error); - }) - .finally(() => { - if (!cancelled) setIsLoading(false); - }); - - return () => { - cancelled = true; - }; - }, []); - - return { usage, isLoading }; + const trpc = useTRPC(); + const { data: usage, isLoading } = useQuery( + trpc.llmGateway.usage.queryOptions(), + ); + return { usage: usage ?? null, isLoading }; } export function PlanUsageSettings() { From 62678f9b0ee5251b9a372818c1d304910e579b2c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 15:58:32 -0700 Subject: [PATCH 31/32] Remove dead selectedPlan and selectedOrgId from authStore --- .../features/auth/stores/authStore.test.ts | 2 -- .../renderer/features/auth/stores/authStore.ts | 16 ---------------- 2 files changed, 18 deletions(-) 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 de94e1763..9f311853f 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.test.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.test.ts @@ -137,8 +137,6 @@ describe("authStore", () => { needsScopeReauth: false, hasCodeAccess: null, hasCompletedOnboarding: false, - selectedPlan: null, - selectedOrgId: null, }); }); diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index e0a5391ca..b74f022d4 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -41,15 +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; selectProject: (projectId: number) => Promise; - selectPlan: (plan: "free" | "pro") => void; - selectOrg: (orgId: string) => void; logout: () => Promise; } @@ -207,8 +203,6 @@ export const useAuthStore = create((set) => ({ hasCodeAccess: null, hasCompletedOnboarding: false, - selectedPlan: null, - selectedOrgId: null, checkCodeAccess: async () => { await syncAuthState(); @@ -244,14 +238,6 @@ export const useAuthStore = create((set) => ({ useNavigationStore.getState().navigateToTaskInput(); }, - selectPlan: (plan: "free" | "pro") => { - set({ selectedPlan: plan }); - }, - - selectOrg: (orgId: string) => { - set({ selectedOrgId: orgId }); - }, - logout: async () => { track(ANALYTICS_EVENTS.USER_LOGGED_OUT); sessionResetCallback?.(); @@ -273,8 +259,6 @@ export const useAuthStore = create((set) => ({ needsProjectSelection: false, needsScopeReauth: false, hasCodeAccess: null, - selectedPlan: null, - selectedOrgId: null, })); inFlightAuthSync = null; inFlightAuthSyncKey = null; From 47a7eb252931d61e5f0a9f55c840fde7678cbb4b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 16 Apr 2026 22:07:07 -0700 Subject: [PATCH 32/32] Guard seat store actions behind billing feature flag --- .../features/billing/stores/seatStore.test.ts | 337 ++++++++++++++++++ .../features/billing/stores/seatStore.ts | 14 + 2 files changed, 351 insertions(+) create mode 100644 apps/code/src/renderer/features/billing/stores/seatStore.test.ts 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 index d681a4939..17a38590b 100644 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ b/apps/code/src/renderer/features/billing/stores/seatStore.ts @@ -5,6 +5,7 @@ import { } 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"; @@ -30,6 +31,14 @@ interface SeatStoreActions { 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) { @@ -79,6 +88,7 @@ export const useSeatStore = create()((set) => ({ 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) { @@ -95,6 +105,7 @@ export const useSeatStore = create()((set) => ({ 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) { @@ -117,6 +128,7 @@ export const useSeatStore = create()((set) => ({ upgradeToPro: async () => { set({ isLoading: true, error: null, redirectUrl: null }); try { + assertBillingEnabled(); const client = await getClient(); const existing = await client.getMySeat(); if (existing) { @@ -138,6 +150,7 @@ export const useSeatStore = create()((set) => ({ cancelSeat: async () => { set({ isLoading: true, error: null, redirectUrl: null }); try { + assertBillingEnabled(); const client = await getClient(); await client.cancelSeat(); const seat = await client.getMySeat(); @@ -150,6 +163,7 @@ export const useSeatStore = create()((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 });