From b91e190eb57b5ead1a9a5c5db26b10508a05f5cb Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 17 Mar 2026 15:15:34 -0700 Subject: [PATCH 01/17] 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 913308d60..343d6dbe5 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -19,11 +19,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; @@ -1093,39 +1111,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 { @@ -1525,6 +1510,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 ad50dae30519ced1a04d7b71231fa728dd4025b6 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 18 Mar 2026 21:23:07 -0700 Subject: [PATCH 02/17] 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 343d6dbe5..2864c9101 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1627,12 +1627,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 3baa5b896a7172ecaee89f15da466c284964dd39 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 15:41:52 -0700 Subject: [PATCH 03/17] 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 2ebc3840e744f54ecffc47eea536be01a520d17f Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 15:57:05 -0700 Subject: [PATCH 04/17] 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 9b2065b40..2a867bd5f 100644 --- a/apps/code/src/shared/constants/oauth.ts +++ b/apps/code/src/shared/constants/oauth.ts @@ -64,6 +64,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 b6b6ed3d70f607077db822230433e50ef6120fd1 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 16:01:29 -0700 Subject: [PATCH 05/17] 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 5a69ff13c..f4dd6bf7e 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 a4d9b0f6c..bbcc113eb 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 661072c71..e2e858e3a 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 67c0a7287..80268023f 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -31,7 +31,6 @@ import { Text, Tooltip, } from "@radix-ui/themes"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import type { ActionabilityJudgmentArtefact, ActionabilityJudgmentContent, @@ -42,6 +41,7 @@ import type { SignalReportArtefactsResponse, 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 924c3cd42..451bbcd80 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -195,7 +195,7 @@ vi.mock("@utils/notifications", () => ({ vi.mock("@renderer/utils/toast", () => ({ toast: { error: 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 d6e457199..adab3cb59 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -37,7 +37,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 CloudTaskUpdatePayload, type EffortLevel, diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx index 7c0f12cfb..3a0bf3618 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx @@ -30,7 +30,7 @@ import { 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 { useCallback, useEffect, useRef, 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 2a867bd5f..0fc51a5ae 100644 --- a/apps/code/src/shared/constants/oauth.ts +++ b/apps/code/src/shared/constants/oauth.ts @@ -53,18 +53,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 70c50f58050af642f713a3eca28b8f209168d9dc Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 19:14:17 -0700 Subject: [PATCH 06/17] 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 0fc51a5ae..c18d600ae 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"; @@ -43,12 +43,6 @@ export const OAUTH_SCOPES = [ export const OAUTH_SCOPE_VERSION = 3; -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 a5bf4b663827e2d8bbfdc06acebc2501a3fa3f6e Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 20:50:24 -0700 Subject: [PATCH 07/17] 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 2864c9101..8bf354195 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1512,12 +1512,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) { @@ -1530,11 +1530,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, @@ -1550,11 +1550,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, @@ -1570,12 +1570,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)) { @@ -1587,11 +1587,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 e9f58ac6d4654474d2ae8a0a6be3ade22293e86c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 20:59:11 -0700 Subject: [PATCH 08/17] 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 adab3cb59..b705295c2 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -37,7 +37,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 CloudTaskUpdatePayload, type EffortLevel, @@ -50,6 +49,7 @@ import { ANALYTICS_EVENTS } from "@shared/types/analytics"; 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 { 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 ecfc683698fd234d5b14dffaca395b3041486229 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 21:41:43 -0700 Subject: [PATCH 09/17] 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 5ab646369647cc5dde8beb69de342585fa20a5fd Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 19 Mar 2026 23:05:29 -0700 Subject: [PATCH 10/17] 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 00e4c7b9c265dbeb66d2d9a83c19d99f60776a55 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Fri, 20 Mar 2026 00:10:15 -0700 Subject: [PATCH 11/17] 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 2c501c29f46f66b4766489ec8ebcbc80b82d46fb Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 1 Apr 2026 15:42:54 -0700 Subject: [PATCH 12/17] 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 c2d05f483..69382c815 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 b4e532911..b8141f845 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 8e206e314..23de1362e 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 { getEffortOptions } from "@posthog/agent/adapters/claude/session/models"; 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 4aa04efe81b39221e573aec177ad664aba050508 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 8 Apr 2026 00:38:54 -0700 Subject: [PATCH 13/17] 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 c83644e76a39bac5f0e13715a32535da4f6e92e8 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 8 Apr 2026 00:49:58 -0700 Subject: [PATCH 14/17] 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 8402745960143b8f69312f8dcb90fd008583ad46 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 8 Apr 2026 01:06:14 -0700 Subject: [PATCH 15/17] 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 c9d4b3e4f..628f27a2c 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 7464ba3f6ac3dcf83992e402d2a5cb0df56f6b71 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 8 Apr 2026 01:40:30 -0700 Subject: [PATCH 16/17] 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 cf0694fe3628c488711bb90d8de1cb66e3c4ed3d Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 8 Apr 2026 01:43:59 -0700 Subject: [PATCH 17/17] 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) => (