From ac623a97bc2cf099ae7794e688ad50987e8ea25e Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 16 Jul 2026 01:09:00 -0400 Subject: [PATCH 1/8] fix(billing): the usage endpoint field is code_usage_subscribed, not code_usage_billed posthog#71315 shipped the bit as code_usage_subscribed; the schema listened for code_usage_billed, which zod strips, so the client would have read "unknown" forever. Rename the field and align internal naming to subscribed. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- .../core/src/billing/usageDisplay.test.ts | 24 +- packages/core/src/billing/usageDisplay.ts | 8 +- packages/shared/src/analytics-events.ts | 14 +- packages/shared/src/flags.ts | 5 + .../billing/UsageBillingAnnouncementModal.tsx | 107 ++++ .../ui/src/features/billing/UsageButton.tsx | 4 +- .../billing/billingAnnouncementStore.ts | 31 ++ .../ui/src/features/billing/useFreeUsage.ts | 16 +- .../settings/components/SettingsPanel.tsx | 7 - .../settings/sections/AccountSettings.tsx | 7 - .../settings/sections/PlanUsageSettings.tsx | 515 +++--------------- packages/ui/src/router/routes/__root.tsx | 3 + 12 files changed, 285 insertions(+), 456 deletions(-) create mode 100644 packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx create mode 100644 packages/ui/src/features/billing/billingAnnouncementStore.ts diff --git a/packages/core/src/billing/usageDisplay.test.ts b/packages/core/src/billing/usageDisplay.test.ts index b9af92bd72..ef06466c3d 100644 --- a/packages/core/src/billing/usageDisplay.test.ts +++ b/packages/core/src/billing/usageDisplay.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { UsageOutput } from "../usage/schemas"; -import { formatResetTime, isUsageExceeded } from "./usageDisplay"; +import { + formatResetTime, + isCodeUsageFreeTier, + isUsageExceeded, +} from "./usageDisplay"; function makeUsage( overrides: Partial<{ @@ -53,6 +57,24 @@ 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("formatResetTime", () => { const NOW = Date.parse("2026-05-01T12:00:00.000Z"); const isoAt = (msFromNow: number) => new Date(NOW + msFromNow).toISOString(); diff --git a/packages/core/src/billing/usageDisplay.ts b/packages/core/src/billing/usageDisplay.ts index 21ef3458d7..cb6f75f813 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -1,7 +1,11 @@ import type { 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 function isUsageExceeded(usage: UsageOutput): boolean { return ( 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/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx new file mode 100644 index 0000000000..e22a124f94 --- /dev/null +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -0,0 +1,107 @@ +import { ArrowSquareOut, CreditCard } from "@phosphor-icons/react"; +import { USAGE_BILLING_FLAG } from "@posthog/shared"; +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 { useFeatureFlag } from "../feature-flags/useFeatureFlag"; +import { useBillingAnnouncementStore } from "./billingAnnouncementStore"; + +/** + * 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 armed = useFeatureFlag(USAGE_BILLING_FLAG); + const acknowledged = useBillingAnnouncementStore((s) => s.acknowledged); + const hasHydrated = useBillingAnnouncementStore((s) => s._hasHydrated); + const acknowledge = useBillingAnnouncementStore((s) => s.acknowledge); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const isLoggedIn = useAuthStateValue((state) => state.currentOrgId !== null); + + const isOpen = armed && isLoggedIn && hasHydrated && !acknowledged; + + 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 — your + organization pays for AI usage at cost, and you only pay for what + you use. + + + + + • Your organization's first $20 of + usage each month is included. + + + • Premium models (Claude, GPT) need a payment method on your + organization; an open model stays available on the free tier. + + + • Every organization starts with a{" "} + $50/month spend limit you can raise, + lower, or remove in PostHog billing settings. + + + + + + + + + + ); +} diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 95b42d3001..a279f538a1 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -121,7 +121,7 @@ export function UsageButton() { >
- Free plan + Free tier handleOpenPlan("titlebar_card")} > - Upgrade + Unlock more
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) => { + state?.setHasHydrated(true); + }, + }, + ), +); diff --git a/packages/ui/src/features/billing/useFreeUsage.ts b/packages/ui/src/features/billing/useFreeUsage.ts index edbe80516f..92a5e002cb 100644 --- a/packages/ui/src/features/billing/useFreeUsage.ts +++ b/packages/ui/src/features/billing/useFreeUsage.ts @@ -1,20 +1,22 @@ +import { isCodeUsageFreeTier } from "@posthog/core/billing/usageDisplay"; 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 + // True when the user is eligible to see the free-tier meter 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 }); + const { usage, isLoading } = useUsage({ enabled: billingEnabled }); - if (!eligible) return { usage: null, isLoading: false }; + if (!billingEnabled) return { usage: null, isLoading: false }; + // Only confirmed free-tier orgs have a meaningful free-tier meter — + // subscribed orgs have no per-user caps, and unknown must not render as free. + if (!isCodeUsageFreeTier(usage)) { + return { usage: null, isLoading: usage ? false : isLoading }; + } return { usage: usage ?? null, 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 +167,45 @@ export function PlanUsageSettings() { > - ) : usage ? ( + ) : freeTier && usage ? ( ) : ( - - - 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 +213,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/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 && } + From f1646c267865db73e03697a308de25d143f1b6f5 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 16 Jul 2026 01:09:03 -0400 Subject: [PATCH 2/8] fix(billing): apply local-testing polish to the billing surfaces formatResetTime rolls 60 minutes into the next hour; isSameUsage compares code_usage_subscribed and ai_credits.exhausted so a mid-session subscribe flip emits UsageUpdated; the plans page keeps only the monthly free meter (the free valve is $20 on both windows, so the monthly cap binds); the announcement modal leads with the headline and tightens the bullets; the cloud preflight reset hint prefers the sustained window. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- .../core/src/billing/usageDisplay.test.ts | 5 ++++ packages/core/src/billing/usageDisplay.ts | 8 ++++-- packages/core/src/usage/usage-monitor.test.ts | 23 +++++++++++++++++ packages/core/src/usage/usage-monitor.ts | 2 ++ .../billing/UsageBillingAnnouncementModal.tsx | 25 ++++++++----------- .../features/billing/preflightCloudUsage.ts | 7 +++--- .../settings/sections/PlanUsageSettings.tsx | 17 ++++--------- 7 files changed, 56 insertions(+), 31 deletions(-) diff --git a/packages/core/src/billing/usageDisplay.test.ts b/packages/core/src/billing/usageDisplay.test.ts index ef06466c3d..b31d1883b2 100644 --- a/packages/core/src/billing/usageDisplay.test.ts +++ b/packages/core/src/billing/usageDisplay.test.ts @@ -95,6 +95,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 cb6f75f813..313c5f9074 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -26,8 +26,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/usage/usage-monitor.test.ts b/packages/core/src/usage/usage-monitor.test.ts index 6e1c8dad70..7da80ab1d8 100644 --- a/packages/core/src/usage/usage-monitor.test.ts +++ b/packages/core/src/usage/usage-monitor.test.ts @@ -246,6 +246,29 @@ describe("UsageMonitorService", () => { expect(updates[1].burst.used_percent).toBe(35); }); + // 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 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..4cbc1c8a1e 100644 --- a/packages/core/src/usage/usage-monitor.ts +++ b/packages/core/src/usage/usage-monitor.ts @@ -245,6 +245,8 @@ 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 && isSameBucket(a.burst, b.burst) && isSameBucket(a.sustained, b.sustained) ); diff --git a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx index e22a124f94..66283904a6 100644 --- a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -65,25 +65,22 @@ export function UsageBillingAnnouncementModal() { - - Seat-based plans are gone. PostHog Code is now usage-based — your - organization pays for AI usage at cost, and you only pay for what - you use. + + Seat-based plans are gone — PostHog Code is now usage-based. You + only pay for what you use. - - • Your organization's first $20 of - usage each month is included. + + • The first $20 of usage each month + is included. - - • Premium models (Claude, GPT) need a payment method on your - organization; an open model stays available on the free tier. + + • Premium models need a payment method; an open model stays free. - - • Every organization starts with a{" "} - $50/month spend limit you can raise, - lower, or remove in PostHog billing settings. + + • A default $50/month spend limit + applies — adjust it any time in billing settings. 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/settings/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index d5d4b6940b..bac83949f0 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -168,18 +168,11 @@ export function PlanUsageSettings() { ) : freeTier && usage ? ( - - - - + ) : ( Date: Thu, 16 Jul 2026 01:09:04 -0400 Subject: [PATCH 3/8] feat(billing): org spend limit in the announcement; hold What's New behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The announcement's spend-limit bullet shows the org's actual limit from ai_credits.limit_usd (posthog#71404) for subscribed orgs — a free org's limit is its included allocation, already the first bullet — falling back to the $50-default copy when unknown. The post-update What's New dialog now waits until the blocking announcement is acknowledged instead of stacking on top of it. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- .../core/src/llm-gateway/llm-gateway.test.ts | 20 ++++++++++- packages/core/src/usage/schemas.ts | 8 ++++- .../billing/UsageBillingAnnouncementModal.tsx | 33 ++++++++++++++----- .../billing/useBillingAnnouncementVisible.ts | 13 ++++++++ .../ui/src/features/updates/WhatsNewModal.tsx | 6 +++- 5 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 packages/ui/src/features/billing/useBillingAnnouncementVisible.ts 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/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx index 66283904a6..b0214e5033 100644 --- a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -1,5 +1,4 @@ import { ArrowSquareOut, CreditCard } from "@phosphor-icons/react"; -import { USAGE_BILLING_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; import { useEffect } from "react"; @@ -7,8 +6,9 @@ import { track } from "../../shell/analytics"; import { openExternalUrl } from "../../shell/openExternal"; import { getBillingUrl } from "../../utils/urls"; import { useAuthStateValue } from "../auth/store"; -import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; import { useBillingAnnouncementStore } from "./billingAnnouncementStore"; +import { useBillingAnnouncementVisible } from "./useBillingAnnouncementVisible"; +import { useUsage } from "./useUsage"; /** * One-time blocking announcement of the usage-based billing cutover. The @@ -16,14 +16,17 @@ import { useBillingAnnouncementStore } from "./billingAnnouncementStore"; * acknowledged. */ export function UsageBillingAnnouncementModal() { - const armed = useFeatureFlag(USAGE_BILLING_FLAG); - const acknowledged = useBillingAnnouncementStore((s) => s.acknowledged); - const hasHydrated = useBillingAnnouncementStore((s) => s._hasHydrated); + const isOpen = useBillingAnnouncementVisible(); const acknowledge = useBillingAnnouncementStore((s) => s.acknowledge); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - const isLoggedIn = useAuthStateValue((state) => state.currentOrgId !== null); + const { usage } = useUsage({ enabled: isOpen }); - const isOpen = armed && isLoggedIn && hasHydrated && !acknowledged; + // 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) { @@ -79,8 +82,20 @@ export function UsageBillingAnnouncementModal() { • Premium models need a payment method; an open model stays free. - • A default $50/month spend limit - applies — adjust it any time in billing settings. + {limitUsd != null ? ( + <> + • Your organization's spend limit is{" "} + + {`$${Number.isInteger(limitUsd) ? limitUsd : limitUsd.toFixed(2)}/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/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/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(); }} From 0804d986355fe2f98b68d8cc141ad997fd13be2e Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 16 Jul 2026 01:09:06 -0400 Subject: [PATCH 4/8] feat(billing): dollar usage surfaces off the org's billing numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codeUsageMeter picks what the meter shows: billing's org-level dollars (ai_credits.used_usd/limit_usd, posthog#71404) when present — for both tiers — else the free tier's per-user valve bucket, else nothing. The titlebar becomes "Usage: $12.40" with an "of $X used" hover card and shows for subscribed orgs too; the plans page meter reads "used of included/ limit" in dollars. Dollars are org-level truth — the valve fallback stays because null numbers are a permanent state (an org before its first billing sync), not just deploy lag. Upgrade-prompt analytics now fire only for free-tier orgs. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- .../core/src/billing/usageDisplay.test.ts | 70 +++++++++++++++++++ packages/core/src/billing/usageDisplay.ts | 46 +++++++++++- .../billing/UsageBillingAnnouncementModal.tsx | 5 +- .../ui/src/features/billing/UsageButton.tsx | 63 ++++++++++------- .../ui/src/features/billing/UsageMeter.tsx | 24 ++++--- .../ui/src/features/billing/useFreeUsage.ts | 22 ------ .../ui/src/features/billing/useUsageMeter.ts | 35 ++++++++++ .../settings/sections/PlanUsageSettings.tsx | 24 +++++-- 8 files changed, 223 insertions(+), 66 deletions(-) delete mode 100644 packages/ui/src/features/billing/useFreeUsage.ts create mode 100644 packages/ui/src/features/billing/useUsageMeter.ts diff --git a/packages/core/src/billing/usageDisplay.test.ts b/packages/core/src/billing/usageDisplay.test.ts index b31d1883b2..d7c3f882fd 100644 --- a/packages/core/src/billing/usageDisplay.test.ts +++ b/packages/core/src/billing/usageDisplay.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import type { UsageOutput } from "../usage/schemas"; import { + codeUsageMeter, formatResetTime, + formatUsdAmount, isCodeUsageFreeTier, isUsageExceeded, } from "./usageDisplay"; @@ -75,6 +77,74 @@ describe("isCodeUsageFreeTier", () => { }); }); +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(); diff --git a/packages/core/src/billing/usageDisplay.ts b/packages/core/src/billing/usageDisplay.ts index 313c5f9074..fcf22434c7 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -1,4 +1,4 @@ -import type { UsageOutput } from "../usage/schemas"; +import type { UsageBucket, UsageOutput } from "../usage/schemas"; /** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */ export function isCodeUsageFreeTier( @@ -7,6 +7,50 @@ export function isCodeUsageFreeTier( 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 ( usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded diff --git a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx index b0214e5033..ec5a21232d 100644 --- a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -1,4 +1,5 @@ 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"; @@ -85,9 +86,7 @@ export function UsageBillingAnnouncementModal() { {limitUsd != null ? ( <> • Your organization's spend limit is{" "} - - {`$${Number.isInteger(limitUsd) ? limitUsd : limitUsd.toFixed(2)}/month`} - {" "} + {`${formatUsdAmount(limitUsd)}/month`}{" "} — 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 a279f538a1..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 tier + {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/useFreeUsage.ts b/packages/ui/src/features/billing/useFreeUsage.ts deleted file mode 100644 index 92a5e002cb..0000000000 --- a/packages/ui/src/features/billing/useFreeUsage.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { isCodeUsageFreeTier } from "@posthog/core/billing/usageDisplay"; -import type { UsageOutput } from "@posthog/core/usage/schemas"; -import { useUsage } from "./useUsage"; - -export interface FreeUsageResult { - usage: UsageOutput | null; - // True when the user is eligible to see the free-tier meter but data - // hasn't arrived yet. Distinguishes "show skeleton" from "render nothing". - isLoading: boolean; -} - -export function useFreeUsage(billingEnabled: boolean): FreeUsageResult { - const { usage, isLoading } = useUsage({ enabled: billingEnabled }); - - if (!billingEnabled) return { usage: null, isLoading: false }; - // Only confirmed free-tier orgs have a meaningful free-tier meter — - // subscribed orgs have no per-user caps, and unknown must not render as free. - if (!isCodeUsageFreeTier(usage)) { - return { usage: null, isLoading: usage ? false : isLoading }; - } - return { usage: usage ?? null, isLoading }; -} 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/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index bac83949f0..09d0db0884 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -3,7 +3,12 @@ import { CreditCard, WarningCircle, } from "@phosphor-icons/react"; -import { isCodeUsageFreeTier } from "@posthog/core/billing/usageDisplay"; +import { + codeUsageMeter, + formatResetTime, + formatUsdAmount, + isCodeUsageFreeTier, +} from "@posthog/core/billing/usageDisplay"; import { Empty, EmptyDescription, @@ -60,6 +65,7 @@ export function PlanUsageSettings() { const freeTier = isCodeUsageFreeTier(usage); const subscribed = usage?.code_usage_subscribed === true; const orgLimitReached = usage?.ai_credits?.exhausted === true; + const meter = codeUsageMeter(usage); const openBilling = () => { if (billingUrl) window.open(billingUrl, "_blank"); @@ -167,11 +173,21 @@ export function PlanUsageSettings() { > - ) : freeTier && usage ? ( + ) : meter.kind === "dollars" ? ( + + ) : meter.kind === "bucket" ? ( ) : ( Date: Thu, 16 Jul 2026 01:09:08 -0400 Subject: [PATCH 5/8] fix(billing): give the threshold toasts the cutover treatment The threshold-crossing toasts were the one seat-era billing surface the stack never touched: they fired on raw valve percentages regardless of subscription state and spoke in "your monthly limit" language. The monitor now emits thresholds only for confirmed free-tier orgs (a subscribed org's valves are internal rails; unknown never reads as free), the copy says "monthly/daily free usage", and the 100% path opens the modal with the org_limit cause so it renders "Free usage used up". Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- packages/core/src/usage/usage-monitor.test.ts | 27 +++++++++++++++++++ packages/core/src/usage/usage-monitor.ts | 4 +++ .../features/billing/billing.contribution.ts | 13 +++++---- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/core/src/usage/usage-monitor.test.ts b/packages/core/src/usage/usage-monitor.test.ts index 7da80ab1d8..669bf2d193 100644 --- a/packages/core/src/usage/usage-monitor.test.ts +++ b/packages/core/src/usage/usage-monitor.test.ts @@ -75,6 +75,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 +247,32 @@ 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 subscription bit flips", async () => { diff --git a/packages/core/src/usage/usage-monitor.ts b/packages/core/src/usage/usage-monitor.ts index 4cbc1c8a1e..d4ad20c572 100644 --- a/packages/core/src/usage/usage-monitor.ts +++ b/packages/core/src/usage/usage-monitor.ts @@ -1,6 +1,7 @@ import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; import { TypedEventEmitter } from "@posthog/shared"; import { inject, injectable, postConstruct, preDestroy } from "inversify"; +import { isCodeUsageFreeTier } from "../billing/usageDisplay"; import { USAGE_HOST, type UsageHost, type UsageLogger } from "./identifiers"; import { USAGE_THRESHOLDS, @@ -125,6 +126,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); 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, From f9074a630de134f01714bef491adb01fe5246a3c Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 16 Jul 2026 01:09:09 -0400 Subject: [PATCH 6/8] style(billing): satisfy biome ci formatting Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- packages/core/src/usage/usage-monitor.test.ts | 4 +--- .../ui/src/features/billing/UsageBillingAnnouncementModal.tsx | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/src/usage/usage-monitor.test.ts b/packages/core/src/usage/usage-monitor.test.ts index 669bf2d193..20a29fc85e 100644 --- a/packages/core/src/usage/usage-monitor.test.ts +++ b/packages/core/src/usage/usage-monitor.test.ts @@ -260,9 +260,7 @@ describe("UsageMonitorService", () => { service = makeService(mockGateway(usage), makeActivityMonitor()); const thresholds: unknown[] = []; const updates: unknown[] = []; - service.on(UsageMonitorEvent.ThresholdCrossed, (e) => - thresholds.push(e), - ); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => thresholds.push(e)); service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); await service.fetchOnce(); diff --git a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx index ec5a21232d..b2484be203 100644 --- a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -91,8 +91,8 @@ export function UsageBillingAnnouncementModal() { ) : ( <> - • A default $50/month spend - limit applies — adjust it any time in billing settings. + • A default $50/month spend limit + applies — adjust it any time in billing settings. )} From 2933a8399d8387af875b6a26ced57054f825c7e5 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 16 Jul 2026 01:09:11 -0400 Subject: [PATCH 7/8] fix(billing): scope the usage snapshot to the identity; emit on dollar changes; fail hydration open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: isSameUsage now compares ai_credits.used_usd/limit_usd so a subscribed org's dollar meter doesn't freeze all period (nothing else in its snapshot ever changes); the usage monitor clears its snapshot on org transitions and the renderer drops the cached query on identity change, so a new sign-in never sees the previous account's spend; a failed announcement-store hydration now fails open as unacknowledged — re-showing the one-time modal beats never showing it, and in-memory state still dismisses it for the session. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- packages/core/src/usage/usage-monitor.test.ts | 82 ++++++++++++++++++- packages/core/src/usage/usage-monitor.ts | 16 ++++ .../ui/src/features/auth/useAuthSession.ts | 4 + .../billing/billingAnnouncementStore.ts | 9 +- packages/ui/src/features/billing/useUsage.ts | 2 +- 5 files changed, 110 insertions(+), 3 deletions(-) diff --git a/packages/core/src/usage/usage-monitor.test.ts b/packages/core/src/usage/usage-monitor.test.ts index 20a29fc85e..0a44fe6d8a 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,26 @@ 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 +80,7 @@ function makeService( ...activity, ...makeThresholdStore(), }; - return new UsageMonitorService(host, makeLogger()); + return new UsageMonitorService(host, makeLogger(), makeAuthService()); } function makeUsage(overrides?: { @@ -273,6 +294,65 @@ describe("UsageMonitorService", () => { // 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 = { diff --git a/packages/core/src/usage/usage-monitor.ts b/packages/core/src/usage/usage-monitor.ts index d4ad20c572..b394ee512b 100644 --- a/packages/core/src/usage/usage-monitor.ts +++ b/packages/core/src/usage/usage-monitor.ts @@ -1,6 +1,9 @@ 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 { @@ -34,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; @@ -251,6 +265,8 @@ function isSameUsage(a: UsageOutput | null, b: UsageOutput): boolean { 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/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/billingAnnouncementStore.ts b/packages/ui/src/features/billing/billingAnnouncementStore.ts index 87a799a7ac..8da4ee9803 100644 --- a/packages/ui/src/features/billing/billingAnnouncementStore.ts +++ b/packages/ui/src/features/billing/billingAnnouncementStore.ts @@ -24,7 +24,14 @@ export const useBillingAnnouncementStore = create()( storage: electronStorage, partialize: (state) => ({ acknowledged: state.acknowledged }), onRehydrateStorage: () => (state) => { - state?.setHasHydrated(true); + 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/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(); From 8cc354b8ee6177faffddb2e559639a960d7a05bf Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 16 Jul 2026 01:09:13 -0400 Subject: [PATCH 8/8] style(billing): format the auth harness in the monitor test Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec --- packages/core/src/usage/usage-monitor.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/usage/usage-monitor.test.ts b/packages/core/src/usage/usage-monitor.test.ts index 0a44fe6d8a..3f91589e94 100644 --- a/packages/core/src/usage/usage-monitor.test.ts +++ b/packages/core/src/usage/usage-monitor.test.ts @@ -54,9 +54,7 @@ type GatewaySlice = Pick; let emitAuthState: (currentOrgId: string | null) => void = () => {}; function makeAuthService(): AuthService { - const listeners = new Set< - (state: { currentOrgId: string | null }) => void - >(); + const listeners = new Set<(state: { currentOrgId: string | null }) => void>(); emitAuthState = (currentOrgId) => { for (const listener of [...listeners]) { listener({ currentOrgId });