diff --git a/packages/core/src/billing/usageDisplay.test.ts b/packages/core/src/billing/usageDisplay.test.ts index b9af92bd72..d7c3f882fd 100644 --- a/packages/core/src/billing/usageDisplay.test.ts +++ b/packages/core/src/billing/usageDisplay.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import type { UsageOutput } from "../usage/schemas"; -import { formatResetTime, isUsageExceeded } from "./usageDisplay"; +import { + codeUsageMeter, + formatResetTime, + formatUsdAmount, + isCodeUsageFreeTier, + isUsageExceeded, +} from "./usageDisplay"; function makeUsage( overrides: Partial<{ @@ -53,6 +59,92 @@ describe("isUsageExceeded", () => { }); }); +describe("isCodeUsageFreeTier", () => { + it.each([ + [false, true], + [true, false], + // Absent means unknown, never free. + [undefined, false], + ] as const)("code_usage_subscribed=%s -> %s", (subscribed, expected) => { + expect(isCodeUsageFreeTier({ code_usage_subscribed: subscribed })).toBe( + expected, + ); + }); + + it("treats missing usage as not confirmed free", () => { + expect(isCodeUsageFreeTier(null)).toBe(false); + expect(isCodeUsageFreeTier(undefined)).toBe(false); + }); +}); + +describe("codeUsageMeter", () => { + it("prefers billing's org dollars when both numbers are present", () => { + const meter = codeUsageMeter({ + ...makeUsage(), + code_usage_subscribed: true, + ai_credits: { exhausted: false, used_usd: 12.4, limit_usd: 50 }, + billing_period_end: "2026-06-01T00:00:00.000Z", + }); + expect(meter).toEqual({ + kind: "dollars", + usedUsd: 12.4, + limitUsd: 50, + percent: 25, + exceeded: false, + resetAt: "2026-06-01T00:00:00.000Z", + }); + }); + + it("marks the dollars meter exceeded from the org bucket and falls back to the sustained reset", () => { + const meter = codeUsageMeter({ + ...makeUsage(), + code_usage_subscribed: false, + ai_credits: { exhausted: true, used_usd: 20, limit_usd: 20 }, + }); + expect(meter).toMatchObject({ + kind: "dollars", + percent: 100, + exceeded: true, + resetAt: "2026-05-01T13:00:00.000Z", + }); + }); + + it.each([ + ["missing numbers", { exhausted: false }], + ["null numbers", { exhausted: false, used_usd: null, limit_usd: null }], + ["a zero limit", { exhausted: false, used_usd: 0, limit_usd: 0 }], + ])("falls back to the free-tier valve bucket with %s", (_name, aiCredits) => { + const usage: UsageOutput = { + ...makeUsage(), + code_usage_subscribed: false, + ai_credits: aiCredits, + }; + expect(codeUsageMeter(usage)).toEqual({ + kind: "bucket", + bucket: usage.sustained, + }); + }); + + it("hides the meter for a subscribed or unknown org without dollars", () => { + expect( + codeUsageMeter({ ...makeUsage(), code_usage_subscribed: true }), + ).toEqual({ kind: "hidden" }); + expect(codeUsageMeter(makeUsage())).toEqual({ kind: "hidden" }); + expect(codeUsageMeter(null)).toEqual({ kind: "hidden" }); + }); +}); + +describe("formatUsdAmount", () => { + it.each([ + [50, "$50"], + [12.4, "$12.40"], + [0.5, "$0.50"], + [0, "$0"], + ])("formats %s as %s", (amount, expected) => { + expect(formatUsdAmount(amount)).toBe(expected); + }); +}); + describe("formatResetTime", () => { const NOW = Date.parse("2026-05-01T12:00:00.000Z"); const isoAt = (msFromNow: number) => new Date(NOW + msFromNow).toISOString(); @@ -73,6 +165,11 @@ describe("formatResetTime", () => { resetAt: isoAt(4 * 3600 * 1000), expected: "Resets in 4h", }, + { + name: "rolls the hour instead of showing 60 minutes", + resetAt: isoAt((23 * 3600 + 59 * 60 + 40) * 1000), + expected: "Resets in 24h", + }, { name: "returns localized date when over 24h away", resetAt: isoAt(30 * 86400 * 1000), diff --git a/packages/core/src/billing/usageDisplay.ts b/packages/core/src/billing/usageDisplay.ts index 21ef3458d7..fcf22434c7 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -1,7 +1,55 @@ -import type { UsageOutput } from "../usage/schemas"; +import type { UsageBucket, UsageOutput } from "../usage/schemas"; -/** How much more usage the Pro plan offers relative to the Free plan. */ -export const PRO_USAGE_MULTIPLIER = 40; +/** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */ +export function isCodeUsageFreeTier( + usage: Pick | null | undefined, +): boolean { + return usage?.code_usage_subscribed === false; +} + +export type CodeUsageMeter = + | { + kind: "dollars"; + usedUsd: number; + limitUsd: number; + percent: number; + exceeded: boolean; + resetAt: string; + } + | { kind: "bucket"; bucket: UsageBucket } + | { kind: "hidden" }; + +/** + * What the usage meter should show. Billing's org-level dollars win when + * present; a free-tier org without them falls back to its per-user valve + * bucket; anything else shows nothing — per-user valve percentages are + * meaningless for a subscribed org, and unknown must not render as free. + */ +export function codeUsageMeter( + usage: UsageOutput | null | undefined, +): CodeUsageMeter { + if (!usage) return { kind: "hidden" }; + const usedUsd = usage.ai_credits?.used_usd; + const limitUsd = usage.ai_credits?.limit_usd; + if (usedUsd != null && limitUsd != null && limitUsd > 0) { + return { + kind: "dollars", + usedUsd, + limitUsd, + percent: Math.min(100, Math.round((usedUsd / limitUsd) * 100)), + exceeded: usage.ai_credits?.exhausted === true, + resetAt: usage.billing_period_end ?? usage.sustained.reset_at, + }; + } + if (isCodeUsageFreeTier(usage)) { + return { kind: "bucket", bucket: usage.sustained }; + } + return { kind: "hidden" }; +} + +export function formatUsdAmount(amount: number): string { + return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`; +} export function isUsageExceeded(usage: UsageOutput): boolean { return ( @@ -22,8 +70,12 @@ export function formatResetTime( const totalHours = ms / 3_600_000; if (totalHours < 24) { - const hours = Math.floor(totalHours); - const minutes = Math.round((totalHours - hours) * 60); + let hours = Math.floor(totalHours); + let minutes = Math.round((totalHours - hours) * 60); + if (minutes === 60) { + hours += 1; + minutes = 0; + } return minutes === 0 ? `Resets in ${hours}h` : `Resets in ${hours}h ${minutes}m`; diff --git a/packages/core/src/llm-gateway/llm-gateway.test.ts b/packages/core/src/llm-gateway/llm-gateway.test.ts index b87f281b5e..b9306a3ed8 100644 --- a/packages/core/src/llm-gateway/llm-gateway.test.ts +++ b/packages/core/src/llm-gateway/llm-gateway.test.ts @@ -368,7 +368,7 @@ describe("LlmGatewayService.fetchUsage", () => { const fetchMock = vi.fn().mockResolvedValue( createJsonResponse({ ...USAGE_BODY, - ai_credits: { exhausted: true }, + ai_credits: { exhausted: true, used_usd: 12.4, limit_usd: 50 }, code_usage_subscribed: true, }), ); @@ -377,9 +377,27 @@ describe("LlmGatewayService.fetchUsage", () => { const usage = await service.fetchUsage(); expect(usage.ai_credits?.exhausted).toBe(true); + expect(usage.ai_credits?.used_usd).toBe(12.4); + expect(usage.ai_credits?.limit_usd).toBe(50); expect(usage.code_usage_subscribed).toBe(true); }); + it("parses ai_credits with null spend numbers from an unsynced org", async () => { + const fetchMock = vi.fn().mockResolvedValue( + createJsonResponse({ + ...USAGE_BODY, + ai_credits: { exhausted: false, used_usd: null, limit_usd: null }, + }), + ); + const { service } = createService(fetchMock); + + const usage = await service.fetchUsage(); + + expect(usage.ai_credits?.exhausted).toBe(false); + expect(usage.ai_credits?.used_usd).toBeNull(); + expect(usage.ai_credits?.limit_usd).toBeNull(); + }); + it("feeds code_usage_subscribed into helper model routing", async () => { const fetchMock = vi .fn() diff --git a/packages/core/src/usage/schemas.ts b/packages/core/src/usage/schemas.ts index 905511bfaf..1ecea3b488 100644 --- a/packages/core/src/usage/schemas.ts +++ b/packages/core/src/usage/schemas.ts @@ -11,7 +11,13 @@ export const usageOutput = z.object({ user_id: z.number(), sustained: usageBucketSchema, burst: usageBucketSchema, - ai_credits: z.object({ exhausted: z.boolean() }).optional(), + ai_credits: z + .object({ + exhausted: z.boolean(), + used_usd: z.number().nullish(), + limit_usd: z.number().nullish(), + }) + .optional(), is_rate_limited: z.boolean(), is_pro: z.boolean(), code_usage_subscribed: z.boolean().optional(), diff --git a/packages/core/src/usage/usage-monitor.test.ts b/packages/core/src/usage/usage-monitor.test.ts index 6e1c8dad70..3f91589e94 100644 --- a/packages/core/src/usage/usage-monitor.test.ts +++ b/packages/core/src/usage/usage-monitor.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthService } from "../auth/auth"; import type { UsageHost } from "./identifiers"; import { UsageMonitorEvent } from "./monitor-schemas"; import type { UsageOutput } from "./schemas"; @@ -50,6 +51,24 @@ function makeLogger() { type GatewaySlice = Pick; +let emitAuthState: (currentOrgId: string | null) => void = () => {}; + +function makeAuthService(): AuthService { + const listeners = new Set<(state: { currentOrgId: string | null }) => void>(); + emitAuthState = (currentOrgId) => { + for (const listener of [...listeners]) { + listener({ currentOrgId }); + } + }; + return { + getState: () => ({ currentOrgId: "org-1" }), + on: ( + _event: string, + listener: (state: { currentOrgId: string | null }) => void, + ) => listeners.add(listener), + } as unknown as AuthService; +} + function makeService( gateway: GatewaySlice, activity: ActivitySlice, @@ -59,7 +78,7 @@ function makeService( ...activity, ...makeThresholdStore(), }; - return new UsageMonitorService(host, makeLogger()); + return new UsageMonitorService(host, makeLogger(), makeAuthService()); } function makeUsage(overrides?: { @@ -75,6 +94,7 @@ function makeUsage(overrides?: { user_id: 42, is_rate_limited: false, is_pro: overrides?.isPro ?? false, + code_usage_subscribed: false, billing_period_end: overrides?.billingPeriodEnd === undefined ? null @@ -246,6 +266,112 @@ describe("UsageMonitorService", () => { expect(updates[1].burst.used_percent).toBe(35); }); + it.each([ + ["subscribed", true], + ["unknown", undefined], + ] as const)( + "does not emit thresholds for a %s org's internal valves", + async (_name, subscribed) => { + const usage = { + ...makeUsage({ sustainedPercent: 90 }), + code_usage_subscribed: subscribed, + }; + service = makeService(mockGateway(usage), makeActivityMonitor()); + const thresholds: unknown[] = []; + const updates: unknown[] = []; + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => thresholds.push(e)); + service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); + + await service.fetchOnce(); + + expect(thresholds).toHaveLength(0); + // The snapshot still flows to the meters. + expect(updates).toHaveLength(1); + }, + ); + + // The titlebar meter keys off this bit; a subscribe flip must not wait for + // some other field to change. + it("emits UsageUpdated when only the org spend numbers change", async () => { + const updates: UsageOutput[] = []; + const gateway = { + fetchUsage: vi + .fn() + .mockResolvedValueOnce({ + ...makeUsage(), + ai_credits: { exhausted: false, used_usd: 12.4, limit_usd: 50 }, + }) + .mockResolvedValueOnce({ + ...makeUsage(), + ai_credits: { exhausted: false, used_usd: 13.1, limit_usd: 50 }, + }), + } as unknown as GatewaySlice; + service = makeService(gateway, makeActivityMonitor()); + service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); + + await service.fetchOnce(); + await service.fetchOnce(); + + expect(updates).toHaveLength(2); + expect(updates[1].ai_credits?.used_usd).toBe(13.1); + }); + + it("forgets the snapshot and refetches when the organization changes", async () => { + const gateway = mockGateway(makeUsage()); + service = makeService(gateway, makeActivityMonitor()); + await service.fetchOnce(); + expect(service.getLatest()).not.toBeNull(); + + emitAuthState("org-2"); + + expect(service.getLatest()).toBeNull(); + await vi.advanceTimersByTimeAsync(10_000); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(2); + expect(service.getLatest()).not.toBeNull(); + }); + + it("keeps the snapshot across same-org auth changes", async () => { + service = makeService(mockGateway(makeUsage()), makeActivityMonitor()); + await service.fetchOnce(); + + emitAuthState("org-1"); + + expect(service.getLatest()).not.toBeNull(); + }); + + it("clears the snapshot on sign-out without scheduling a refetch", async () => { + const gateway = mockGateway(makeUsage()); + service = makeService(gateway, makeActivityMonitor()); + await service.fetchOnce(); + + emitAuthState(null); + + expect(service.getLatest()).toBeNull(); + await vi.advanceTimersByTimeAsync(10_000); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(1); + }); + + it("emits UsageUpdated when only the subscription bit flips", async () => { + const updates: UsageOutput[] = []; + const gateway = { + fetchUsage: vi + .fn() + .mockResolvedValueOnce({ + ...makeUsage(), + code_usage_subscribed: false, + }) + .mockResolvedValueOnce({ ...makeUsage(), code_usage_subscribed: true }), + } as unknown as GatewaySlice; + service = makeService(gateway, makeActivityMonitor()); + service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); + + await service.fetchOnce(); + await service.fetchOnce(); + + expect(updates).toHaveLength(2); + expect(updates[1].code_usage_subscribed).toBe(true); + }); + it("does not emit UsageUpdated when the gateway throws", async () => { const updates: UsageOutput[] = []; const gateway = { diff --git a/packages/core/src/usage/usage-monitor.ts b/packages/core/src/usage/usage-monitor.ts index 67fd7d22d1..b394ee512b 100644 --- a/packages/core/src/usage/usage-monitor.ts +++ b/packages/core/src/usage/usage-monitor.ts @@ -1,6 +1,10 @@ import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; import { TypedEventEmitter } from "@posthog/shared"; import { inject, injectable, postConstruct, preDestroy } from "inversify"; +import type { AuthService } from "../auth/auth"; +import { AUTH_SERVICE } from "../auth/auth.module"; +import { AuthServiceEvent } from "../auth/schemas"; +import { isCodeUsageFreeTier } from "../billing/usageDisplay"; import { USAGE_HOST, type UsageHost, type UsageLogger } from "./identifiers"; import { USAGE_THRESHOLDS, @@ -33,10 +37,21 @@ export class UsageMonitorService extends TypedEventEmitter { private readonly host: UsageHost, @inject(ROOT_LOGGER) logger: RootLogger, + @inject(AUTH_SERVICE) + authService: AuthService, ) { super(); this.log = logger.scope("usage-monitor"); this.thresholdsSeen = { ...this.host.getThresholdsSeen() }; + // The snapshot is identity-scoped billing data: a signed-in account must + // never be served the previous account's spend. + let orgId = authService.getState().currentOrgId; + authService.on(AuthServiceEvent.StateChanged, (state) => { + if (state.currentOrgId === orgId) return; + orgId = state.currentOrgId; + this.latestUsage = null; + if (state.currentOrgId !== null) this.requestRefresh(); + }); } private readonly log: UsageLogger; @@ -125,6 +140,9 @@ export class UsageMonitorService extends TypedEventEmitter { } private processUsage(usage: UsageOutput): void { + // Valve thresholds are a free-tier concept — a subscribed org's valves + // are internal rails, and unknown must never read as free. + if (!isCodeUsageFreeTier(usage)) return; const userId = usage.user_id.toString(); const product = usage.product; this.maybeEmit(usage, "burst", usage.burst, userId, product, usage.is_pro); @@ -245,6 +263,10 @@ function isSameUsage(a: UsageOutput | null, b: UsageOutput): boolean { return ( a.is_rate_limited === b.is_rate_limited && a.billing_period_end === b.billing_period_end && + a.code_usage_subscribed === b.code_usage_subscribed && + a.ai_credits?.exhausted === b.ai_credits?.exhausted && + a.ai_credits?.used_usd === b.ai_credits?.used_usd && + a.ai_credits?.limit_usd === b.ai_credits?.limit_usd && isSameBucket(a.burst, b.burst) && isSameBucket(a.sustained, b.sustained) ); diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 52725b417b..b1f313cebc 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -973,7 +973,8 @@ export interface ChannelsSpaceViewedProperties { export type UpgradePromptShownSurface = | "usage_limit_modal" | "upgrade_dialog" - | "titlebar_card"; + | "titlebar_card" + | "billing_announcement"; export type UpgradePromptClickedSurface = | "usage_limit_modal" @@ -981,7 +982,8 @@ export type UpgradePromptClickedSurface = | "titlebar" | "titlebar_card" | "plan_page_card" - | "upgrade_dialog"; + | "upgrade_dialog" + | "billing_announcement"; export type UpgradePromptCause = "model_gate" | "org_limit"; @@ -1000,6 +1002,11 @@ export interface CloudTaskUsageBlockedProperties { is_pro: boolean; } +export interface UsageBillingAnnouncementAcknowledgedProperties { + /** Stamps the acknowledgment on the person for support auditability. */ + $set: { code_usage_billing_acknowledged_at: string }; +} + export interface SubscriptionStartedProperties { plan_key: string; previous_plan_key?: string; @@ -1213,6 +1220,8 @@ export const ANALYTICS_EVENTS = { CLOUD_TASK_USAGE_BLOCKED: "Cloud task usage blocked", SUBSCRIPTION_STARTED: "Subscription started", SUBSCRIPTION_CANCELLED: "Subscription cancelled", + USAGE_BILLING_ANNOUNCEMENT_ACKNOWLEDGED: + "Usage billing announcement acknowledged", // Project Bluebird (Channels) events CHANNELS_SPACE_VIEWED: "Channels space viewed", @@ -1368,6 +1377,7 @@ export type EventPropertyMap = { // Subscription events [ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN]: UpgradePromptShownProperties; [ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED]: UpgradePromptClickedProperties; + [ANALYTICS_EVENTS.USAGE_BILLING_ANNOUNCEMENT_ACKNOWLEDGED]: UsageBillingAnnouncementAcknowledgedProperties; [ANALYTICS_EVENTS.CLOUD_TASK_USAGE_BLOCKED]: CloudTaskUsageBlockedProperties; [ANALYTICS_EVENTS.SUBSCRIPTION_STARTED]: SubscriptionStartedProperties; [ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED]: SubscriptionCancelledProperties; diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index 7f1587d962..35899d7d87 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -1,5 +1,10 @@ export const BILLING_FLAG = "posthog-code-billing"; export const SPEND_ANALYSIS_FLAG = "posthog-code-spend-analysis"; +/** + * Launch switch for the one-time usage-based billing announcement: flip at + * cutover, delete once the fleet has acknowledged. + */ +export const USAGE_BILLING_FLAG = "posthog-code-usage-billing"; export const EXPERIMENT_SUGGESTIONS_FLAG = "posthog-code-experiment-suggestions"; export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; diff --git a/packages/ui/src/features/auth/useAuthSession.ts b/packages/ui/src/features/auth/useAuthSession.ts index ab9f2019f4..44de89593a 100644 --- a/packages/ui/src/features/auth/useAuthSession.ts +++ b/packages/ui/src/features/auth/useAuthSession.ts @@ -1,6 +1,7 @@ import { useHostTRPCClient } from "@posthog/host-router/react"; import { BILLING_FLAG } from "@posthog/shared"; import { useSeatStore } from "@posthog/ui/features/billing/seatStore"; +import { USAGE_QUERY_KEY } from "@posthog/ui/features/billing/useUsage"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { identifyUser, @@ -113,6 +114,9 @@ function useSeatSync( ): void { const queryClient = useQueryClient(); useEffect(() => { + // Usage is identity-scoped billing data — drop the cached snapshot so a + // new sign-in never renders the previous account's spend. + queryClient.removeQueries({ queryKey: USAGE_QUERY_KEY }); if (!authIdentity || !billingEnabled) { useSeatStore.getState().reset(); return; diff --git a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx new file mode 100644 index 0000000000..b2484be203 --- /dev/null +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -0,0 +1,118 @@ +import { ArrowSquareOut, CreditCard } from "@phosphor-icons/react"; +import { formatUsdAmount } from "@posthog/core/billing/usageDisplay"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; +import { useEffect } from "react"; +import { track } from "../../shell/analytics"; +import { openExternalUrl } from "../../shell/openExternal"; +import { getBillingUrl } from "../../utils/urls"; +import { useAuthStateValue } from "../auth/store"; +import { useBillingAnnouncementStore } from "./billingAnnouncementStore"; +import { useBillingAnnouncementVisible } from "./useBillingAnnouncementVisible"; +import { useUsage } from "./useUsage"; + +/** + * One-time blocking announcement of the usage-based billing cutover. The + * flag is its launch switch — flip at cutover, delete once the fleet has + * acknowledged. + */ +export function UsageBillingAnnouncementModal() { + const isOpen = useBillingAnnouncementVisible(); + const acknowledge = useBillingAnnouncementStore((s) => s.acknowledge); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const { usage } = useUsage({ enabled: isOpen }); + + // A free org's limit_usd is its included allocation (the first bullet's + // $20), not a spend limit — the org-specific line is for subscribed orgs. + const limitUsd = + usage?.code_usage_subscribed === true + ? (usage.ai_credits?.limit_usd ?? null) + : null; + + useEffect(() => { + if (isOpen) { + track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, { + surface: "billing_announcement", + }); + } + }, [isOpen]); + + const handleAcknowledge = () => { + track(ANALYTICS_EVENTS.USAGE_BILLING_ANNOUNCEMENT_ACKNOWLEDGED, { + $set: { + code_usage_billing_acknowledged_at: new Date().toISOString(), + }, + }); + acknowledge(); + }; + + const handleManageBilling = () => { + track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { + surface: "billing_announcement", + }); + const billingUrl = getBillingUrl(cloudRegion); + if (billingUrl) openExternalUrl(billingUrl); + }; + + return ( + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + + + + PostHog Code billing has changed + + + + + Seat-based plans are gone — PostHog Code is now usage-based. You + only pay for what you use. + + + + + • The first $20 of usage each month + is included. + + + • Premium models need a payment method; an open model stays free. + + + {limitUsd != null ? ( + <> + • Your organization's spend limit is{" "} + {`${formatUsdAmount(limitUsd)}/month`}{" "} + — adjust it any time in billing settings. + + ) : ( + <> + • A default $50/month spend limit + applies — adjust it any time in billing settings. + + )} + + + + + + + + + + ); +} diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 95b42d3001..8f470b5b8b 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -1,7 +1,7 @@ import { Circle } from "@phosphor-icons/react"; import { formatResetTime, - isUsageExceeded, + formatUsdAmount, } from "@posthog/core/billing/usageDisplay"; import { Button, @@ -23,19 +23,19 @@ import { openSettings, prepareSettingsPage, } from "../settings/hooks/useOpenSettings"; -import { useFreeUsage } from "./useFreeUsage"; +import { useUsageMeter } from "./useUsageMeter"; // Title-bar usage entry point (replaces the old sidebar usage bar): a compact -// "Usage: N%" button whose hover card carries the full plan card — plan name, -// progress bar, reset time, and the Upgrade action. Built on quill's Popover +// usage button whose hover card carries the full plan card — plan name, +// progress bar, reset time, and the plan action. Built on quill's Popover // with `openOnHover` on the trigger, so it behaves as a hover card (Base UI -// keeps it open while the pointer travels into the card to click Upgrade). +// keeps it open while the pointer travels into the card to click the action). // The card body styles with quill tokens (foreground/muted-foreground/primary, // quill Progress) — radix scale classes don't resolve inside the data-quill // popover portal. export function UsageButton() { const billingEnabled = useFeatureFlag(BILLING_FLAG); - const { usage, isLoading } = useFreeUsage(billingEnabled); + const { meter, freeTier, blocked, isLoading } = useUsageMeter(billingEnabled); // Controlled so the trigger click can close the card before navigating to // settings — uncontrolled, the same click would also toggle the popover open // over the settings view. Hover open/close still flows through onOpenChange. @@ -45,7 +45,7 @@ export function UsageButton() { // Same-size placeholder while usage loads, so the button doesn't pop in and // shift the PostHog Web button after boot. - if (!usage) { + if (meter.kind === "hidden") { if (!isLoading) return null; return ( } /> @@ -121,14 +134,14 @@ export function UsageButton() { >
- Free plan + {freeTier ? "Free tier" : "Usage-based billing"} - {exceeded ? "Limit reached" : `${usagePercent}% used`} + {blocked ? "Limit reached" : amountLabel}
{resetLabel} diff --git a/packages/ui/src/features/billing/UsageMeter.tsx b/packages/ui/src/features/billing/UsageMeter.tsx index 65bb71ddd9..9ea1c91a11 100644 --- a/packages/ui/src/features/billing/UsageMeter.tsx +++ b/packages/ui/src/features/billing/UsageMeter.tsx @@ -1,16 +1,20 @@ -import { formatResetTime } from "@posthog/core/billing/usageDisplay"; -import type { UsageBucket } from "@posthog/core/usage/schemas"; import { Flex, Progress, Text } from "@radix-ui/themes"; interface UsageMeterProps { label: string; - bucket: UsageBucket; + percent: number; + valueLabel: string; + detail: string; color?: "red"; } -export function UsageMeter({ label, bucket, color }: UsageMeterProps) { - const percentage = bucket.used_percent; - +export function UsageMeter({ + label, + percent, + valueLabel, + detail, + color, +}: UsageMeterProps) { const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; return ( @@ -25,16 +29,14 @@ export function UsageMeter({ label, bucket, color }: UsageMeterProps) { > {label} - {percentage.toFixed(2)}% + {valueLabel} - - {`${bucket.exceeded ? "Limit exceeded. " : ""}${formatResetTime(bucket.reset_at)}`} - + {detail} ); } diff --git a/packages/ui/src/features/billing/billing.contribution.ts b/packages/ui/src/features/billing/billing.contribution.ts index 1ab8fb3bbf..0b94434ed3 100644 --- a/packages/ui/src/features/billing/billing.contribution.ts +++ b/packages/ui/src/features/billing/billing.contribution.ts @@ -28,22 +28,25 @@ export class BillingContribution implements Contribution { onData: (event) => { const resetLabel = formatResetTime(event.resetAt); + // The monitor only emits thresholds for confirmed free-tier orgs, so + // the free-usage framing (and the org_limit cause) is always right. if (event.threshold === 100) { if (event.userIsActive) { - useUsageLimitStore.getState().show({ resetAt: event.resetAt }); + useUsageLimitStore + .getState() + .show({ resetAt: event.resetAt, cause: "org_limit" }); return; } - toast.error("Usage limit reached", { + toast.error("Free usage used up", { id: `usage-threshold-${event.bucket}-100`, description: resetLabel, }); return; } - const limitName = - event.bucket === "burst" ? "daily limit" : "monthly limit"; + const period = event.bucket === "burst" ? "daily" : "monthly"; toast.warning( - `You've used ${Math.round(event.usedPercent)}% of your ${limitName}`, + `You've used ${Math.round(event.usedPercent)}% of your ${period} free usage`, { id: `usage-threshold-${event.bucket}-${event.threshold}`, description: resetLabel, diff --git a/packages/ui/src/features/billing/billingAnnouncementStore.ts b/packages/ui/src/features/billing/billingAnnouncementStore.ts new file mode 100644 index 0000000000..8da4ee9803 --- /dev/null +++ b/packages/ui/src/features/billing/billingAnnouncementStore.ts @@ -0,0 +1,38 @@ +import { electronStorage } from "@posthog/ui/shell/rendererStorage"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface BillingAnnouncementState { + acknowledged: boolean; + // Hydration is async (Electron storage over IPC); the announcement must not + // flash open before a persisted acknowledgment has been read back. + _hasHydrated: boolean; + acknowledge: () => void; + setHasHydrated: (hydrated: boolean) => void; +} + +export const useBillingAnnouncementStore = create()( + persist( + (set) => ({ + acknowledged: false, + _hasHydrated: false, + acknowledge: () => set({ acknowledged: true }), + setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }), + }), + { + name: "posthog-code-usage-billing-acknowledged", + storage: electronStorage, + partialize: (state) => ({ acknowledged: state.acknowledged }), + onRehydrateStorage: () => (state) => { + if (state) { + state.setHasHydrated(true); + return; + } + // Failed storage read: fail open as unacknowledged — re-showing the + // one-time announcement beats never showing it, and the in-memory + // acknowledgment still dismisses it for the rest of the session. + useBillingAnnouncementStore.setState({ _hasHydrated: true }); + }, + }, + ), +); diff --git a/packages/ui/src/features/billing/preflightCloudUsage.ts b/packages/ui/src/features/billing/preflightCloudUsage.ts index 1fe3b1f7c9..cf989c7b30 100644 --- a/packages/ui/src/features/billing/preflightCloudUsage.ts +++ b/packages/ui/src/features/billing/preflightCloudUsage.ts @@ -13,9 +13,10 @@ import { type UsageLimitShowArgs, useUsageLimitStore } from "./usageLimitStore"; const log = logger.scope("preflight-cloud-usage"); function usageLimitArgs(usage: UsageOutput): UsageLimitShowArgs { - // Reset hint from whichever bucket is exceeded (daily takes priority); - // the monthly bucket covers an org-bucket block, whose period it matches. - const bucket = usage.burst.exceeded ? "burst" : "sustained"; + // Burst-alone is the only state the earlier daily reset unblocks; both + // exceeded or an org-bucket block wait for the sustained window. + const bucket = + usage.burst.exceeded && !usage.sustained.exceeded ? "burst" : "sustained"; return { resetAt: usage[bucket].reset_at, cause: "org_limit" }; } diff --git a/packages/ui/src/features/billing/useBillingAnnouncementVisible.ts b/packages/ui/src/features/billing/useBillingAnnouncementVisible.ts new file mode 100644 index 0000000000..5c2c4dbc36 --- /dev/null +++ b/packages/ui/src/features/billing/useBillingAnnouncementVisible.ts @@ -0,0 +1,13 @@ +import { USAGE_BILLING_FLAG } from "@posthog/shared"; +import { useAuthStateValue } from "../auth/store"; +import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; +import { useBillingAnnouncementStore } from "./billingAnnouncementStore"; + +/** Whether the one-time billing announcement is currently blocking the app. */ +export function useBillingAnnouncementVisible(): boolean { + const armed = useFeatureFlag(USAGE_BILLING_FLAG); + const acknowledged = useBillingAnnouncementStore((s) => s.acknowledged); + const hasHydrated = useBillingAnnouncementStore((s) => s._hasHydrated); + const isLoggedIn = useAuthStateValue((state) => state.currentOrgId !== null); + return armed && isLoggedIn && hasHydrated && !acknowledged; +} diff --git a/packages/ui/src/features/billing/useFreeUsage.ts b/packages/ui/src/features/billing/useFreeUsage.ts deleted file mode 100644 index edbe80516f..0000000000 --- a/packages/ui/src/features/billing/useFreeUsage.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { UsageOutput } from "@posthog/core/usage/schemas"; -import { useSeat } from "./useSeat"; -import { useUsage } from "./useUsage"; - -export interface FreeUsageResult { - usage: UsageOutput | null; - // True when the user is eligible to see the Free sidebar bar but data - // hasn't arrived yet. Distinguishes "show skeleton" from "render nothing". - isLoading: boolean; -} - -export function useFreeUsage(billingEnabled: boolean): FreeUsageResult { - const { seat, isPro } = useSeat(); - const seatLoaded = seat !== null; - const eligible = billingEnabled && seatLoaded && !isPro; - const { usage, isLoading } = useUsage({ enabled: eligible }); - - if (!eligible) return { usage: null, isLoading: false }; - return { usage: usage ?? null, isLoading }; -} diff --git a/packages/ui/src/features/billing/useUsage.ts b/packages/ui/src/features/billing/useUsage.ts index 0689b82503..4d138cb8ed 100644 --- a/packages/ui/src/features/billing/useUsage.ts +++ b/packages/ui/src/features/billing/useUsage.ts @@ -2,7 +2,7 @@ import { useHostTRPCClient } from "@posthog/host-router/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect } from "react"; -const USAGE_QUERY_KEY = ["billing", "usage", "latest"] as const; +export const USAGE_QUERY_KEY = ["billing", "usage", "latest"] as const; export function useUsage({ enabled = true }: { enabled?: boolean } = {}) { const client = useHostTRPCClient(); diff --git a/packages/ui/src/features/billing/useUsageMeter.ts b/packages/ui/src/features/billing/useUsageMeter.ts new file mode 100644 index 0000000000..5385987ee2 --- /dev/null +++ b/packages/ui/src/features/billing/useUsageMeter.ts @@ -0,0 +1,35 @@ +import { + type CodeUsageMeter, + codeUsageMeter, + isCodeUsageFreeTier, + isUsageExceeded, +} from "@posthog/core/billing/usageDisplay"; +import { useUsage } from "./useUsage"; + +export interface UsageMeterState { + meter: CodeUsageMeter; + freeTier: boolean; + blocked: boolean; + // True while the meter could still appear but data hasn't arrived — + // distinguishes "show skeleton" from "render nothing". + isLoading: boolean; +} + +export function useUsageMeter(billingEnabled: boolean): UsageMeterState { + const { usage, isLoading } = useUsage({ enabled: billingEnabled }); + + if (!billingEnabled) { + return { + meter: { kind: "hidden" }, + freeTier: false, + blocked: false, + isLoading: false, + }; + } + return { + meter: codeUsageMeter(usage), + freeTier: isCodeUsageFreeTier(usage), + blocked: usage ? isUsageExceeded(usage) : false, + isLoading: !usage && isLoading, + }; +} diff --git a/packages/ui/src/features/settings/components/SettingsPanel.tsx b/packages/ui/src/features/settings/components/SettingsPanel.tsx index 49870121fd..87f698a212 100644 --- a/packages/ui/src/features/settings/components/SettingsPanel.tsx +++ b/packages/ui/src/features/settings/components/SettingsPanel.tsx @@ -25,7 +25,6 @@ import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { useLogoutMutation } from "@posthog/ui/features/auth/useAuthMutations"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; -import { useSeat } from "@posthog/ui/features/billing/useSeat"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { closeSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { AdvancedSettings } from "@posthog/ui/features/settings/sections/AdvancedSettings"; @@ -166,7 +165,6 @@ export function SettingsPanel({ ); const client = useOptionalAuthenticatedClient(); const { data: user } = useCurrentUser({ client }); - const { seat, planLabel } = useSeat(); const billingEnabled = useFeatureFlag(BILLING_FLAG); const { localWorkspaces } = useHostCapabilities(); const logoutMutation = useLogoutMutation(); @@ -234,11 +232,6 @@ export function SettingsPanel({ {user.email} - {seat && ( - - {planLabel} Plan - - )} )} diff --git a/packages/ui/src/features/settings/sections/AccountSettings.tsx b/packages/ui/src/features/settings/sections/AccountSettings.tsx index 9d582049a9..a5e4838c8f 100644 --- a/packages/ui/src/features/settings/sections/AccountSettings.tsx +++ b/packages/ui/src/features/settings/sections/AccountSettings.tsx @@ -5,7 +5,6 @@ import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { useLogoutMutation } from "@posthog/ui/features/auth/useAuthMutations"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; -import { useSeat } from "@posthog/ui/features/billing/useSeat"; import { Avatar, Badge, Button, Flex, Spinner, Text } from "@radix-ui/themes"; export function AccountSettings() { @@ -19,7 +18,6 @@ export function AccountSettings() { client, enabled: isAuthenticated, }); - const { seat, isPro, planLabel } = useSeat(); const handleLogout = () => { logoutMutation.mutate(); @@ -65,11 +63,6 @@ export function AccountSettings() { {formatRegionBadge(cloudRegion)} )} - {seat && ( - - {planLabel} - - )} - - - - )} - - {hasBetterPlanElsewhere && seat?.organization_name && ( - - - - - - - - You have a Pro plan on{" "} - {seat.organization_name}. Usage - on this page reflects your current organization. - - {billingOrgId && ( - - - {switchOrgMutation.isError && ( - - Switching failed. Try again or switch from the - sidebar. - - )} - - )} - - - - )} - - - {orgSeat ? ( - <> - - - {isLoading ? : "Reactivate"} - - ) : ( - - ) - ) : ( - - ) - } - /> - - ) : ( - - {isLoading ? ( - - ) : ( - - No plan selected - - )} - - )} - - - {isAlpha && ( - - - Extended Alpha Plan + + + + + {freeTier ? "Free tier" : "Usage-based billing"} + - You're on the free Pro plan with full Pro features until June - 4, 2026. Once your alpha seat expires, you'll be moved to the - free plan automatically and will be able to upgrade to the Pro - plan. + {freeTier + ? "Your organization's first $20 of usage each month is included, with access to open models. Add a payment method to unlock premium models — you only pay for what you use." + : "Your organization pays for PostHog Code usage at cost — no seats, no subscriptions. The first $20 each month is included."} + {subscribed && ( + + Active + + )} - )} + + Usage @@ -372,115 +173,48 @@ export function PlanUsageSettings() { > - ) : usage ? ( - - - - + ) : meter.kind === "dollars" ? ( + + ) : meter.kind === "bucket" ? ( + ) : ( - - - Unable to load usage data - - - )} - - - {isOrgPro && ( - - - Billing - - - - Manage billing and invoices - - - - - )} - - - Upgrade to Pro - - Pro is for teams using Code as part of their daily development - workflow: longer cloud runs, repeated agent iterations, and - fewer stops as work scales.{" "} - {seat?.organization_name ? ( - {seat.organization_name} - ) : ( - "Your organization" - )}{" "} - will be charged $200/month for {PRO_USAGE_MULTIPLIER}× the Free - usage limit. - - - - - Your first charge is prorated for the remainder of the current - billing cycle, then $200/month thereafter. + + {usage + ? "Usage is billed to your organization. View detailed usage and spend in PostHog." + : "Unable to load usage data"} - - - - - - + )} - - + )} + )} @@ -488,69 +222,3 @@ export function PlanUsageSettings() { ); } - -interface PlanCardProps { - name: string; - price: string; - period: string; - isCurrent: boolean; - resetLabel?: string; - badge?: string; - action?: React.ReactNode; -} - -function PlanCard({ - name, - price, - period, - isCurrent, - resetLabel, - badge, - action, -}: PlanCardProps) { - return ( - - {badge && ( - - {badge} - - )} - - - - {isCurrent ? "CURRENT PLAN" : name.toUpperCase()} - - - {name} - - {price} - {period} - - - {resetLabel && ( - {resetLabel} - )} - - - {action} - - ); -} diff --git a/packages/ui/src/features/updates/WhatsNewModal.tsx b/packages/ui/src/features/updates/WhatsNewModal.tsx index d8e9e3a63a..a155d69204 100644 --- a/packages/ui/src/features/updates/WhatsNewModal.tsx +++ b/packages/ui/src/features/updates/WhatsNewModal.tsx @@ -1,5 +1,6 @@ import { X } from "@phosphor-icons/react"; import { useHostTRPC } from "@posthog/host-router/react"; +import { useBillingAnnouncementVisible } from "@posthog/ui/features/billing/useBillingAnnouncementVisible"; import { ReleaseNotesSections } from "@posthog/ui/features/updates/ReleaseNotesSections"; import { groupReleases, @@ -42,6 +43,9 @@ function ChangelogSkeleton() { export function WhatsNewModal() { const isOpen = useWhatsNewStore((state) => state.isOpen); const close = useWhatsNewStore((state) => state.close); + // The blocking billing announcement takes the stage alone — the post-update + // auto-open waits here until it's acknowledged, then appears. + const billingAnnouncementVisible = useBillingAnnouncementVisible(); const prefetchForActiveUpdate = useHasActiveUpdate(); const hostTRPC = useHostTRPC(); const { data: currentVersion, isError: isVersionError } = useQuery( @@ -63,7 +67,7 @@ export function WhatsNewModal() { return ( { if (!open) close(); }} diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index f2483c7cf9..d5544ddc46 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -16,6 +16,7 @@ import { isContentlessTask } from "@posthog/shared/domain-types"; import { DeepLinkApprovalModal } from "@posthog/ui/features/agent-applications/components/DeepLinkApprovalModal"; import { useApprovalDeepLink } from "@posthog/ui/features/agent-applications/hooks/useApprovalDeepLink"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { UsageBillingAnnouncementModal } from "@posthog/ui/features/billing/UsageBillingAnnouncementModal"; import { UsageButton } from "@posthog/ui/features/billing/UsageButton"; import { UsageLimitModal } from "@posthog/ui/features/billing/UsageLimitModal"; import { BlankTabView } from "@posthog/ui/features/browser-tabs/BlankTabView"; @@ -326,6 +327,7 @@ function RootLayout() { onToggleShortcutsSheet={toggleShortcutsSheet} /> {billingEnabled && } + @@ -501,6 +503,7 @@ function RootLayout() { /> {billingEnabled && } +