Skip to content

Commit 25c1733

Browse files
authored
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
1 parent d8738b9 commit 25c1733

12 files changed

Lines changed: 285 additions & 456 deletions

File tree

packages/core/src/billing/usageDisplay.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from "vitest";
22
import type { UsageOutput } from "../usage/schemas";
3-
import { formatResetTime, isUsageExceeded } from "./usageDisplay";
3+
import {
4+
formatResetTime,
5+
isCodeUsageFreeTier,
6+
isUsageExceeded,
7+
} from "./usageDisplay";
48

59
function makeUsage(
610
overrides: Partial<{
@@ -53,6 +57,24 @@ describe("isUsageExceeded", () => {
5357
});
5458
});
5559

60+
describe("isCodeUsageFreeTier", () => {
61+
it.each([
62+
[false, true],
63+
[true, false],
64+
// Absent means unknown, never free.
65+
[undefined, false],
66+
] as const)("code_usage_subscribed=%s -> %s", (subscribed, expected) => {
67+
expect(isCodeUsageFreeTier({ code_usage_subscribed: subscribed })).toBe(
68+
expected,
69+
);
70+
});
71+
72+
it("treats missing usage as not confirmed free", () => {
73+
expect(isCodeUsageFreeTier(null)).toBe(false);
74+
expect(isCodeUsageFreeTier(undefined)).toBe(false);
75+
});
76+
});
77+
5678
describe("formatResetTime", () => {
5779
const NOW = Date.parse("2026-05-01T12:00:00.000Z");
5880
const isoAt = (msFromNow: number) => new Date(NOW + msFromNow).toISOString();

packages/core/src/billing/usageDisplay.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import type { UsageOutput } from "../usage/schemas";
22

3-
/** How much more usage the Pro plan offers relative to the Free plan. */
4-
export const PRO_USAGE_MULTIPLIER = 40;
3+
/** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */
4+
export function isCodeUsageFreeTier(
5+
usage: Pick<UsageOutput, "code_usage_subscribed"> | null | undefined,
6+
): boolean {
7+
return usage?.code_usage_subscribed === false;
8+
}
59

610
export function isUsageExceeded(usage: UsageOutput): boolean {
711
return (

packages/shared/src/analytics-events.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -973,15 +973,17 @@ export interface ChannelsSpaceViewedProperties {
973973
export type UpgradePromptShownSurface =
974974
| "usage_limit_modal"
975975
| "upgrade_dialog"
976-
| "titlebar_card";
976+
| "titlebar_card"
977+
| "billing_announcement";
977978

978979
export type UpgradePromptClickedSurface =
979980
| "usage_limit_modal"
980981
| "sidebar"
981982
| "titlebar"
982983
| "titlebar_card"
983984
| "plan_page_card"
984-
| "upgrade_dialog";
985+
| "upgrade_dialog"
986+
| "billing_announcement";
985987

986988
export type UpgradePromptCause = "model_gate" | "org_limit";
987989

@@ -1000,6 +1002,11 @@ export interface CloudTaskUsageBlockedProperties {
10001002
is_pro: boolean;
10011003
}
10021004

1005+
export interface UsageBillingAnnouncementAcknowledgedProperties {
1006+
/** Stamps the acknowledgment on the person for support auditability. */
1007+
$set: { code_usage_billing_acknowledged_at: string };
1008+
}
1009+
10031010
export interface SubscriptionStartedProperties {
10041011
plan_key: string;
10051012
previous_plan_key?: string;
@@ -1213,6 +1220,8 @@ export const ANALYTICS_EVENTS = {
12131220
CLOUD_TASK_USAGE_BLOCKED: "Cloud task usage blocked",
12141221
SUBSCRIPTION_STARTED: "Subscription started",
12151222
SUBSCRIPTION_CANCELLED: "Subscription cancelled",
1223+
USAGE_BILLING_ANNOUNCEMENT_ACKNOWLEDGED:
1224+
"Usage billing announcement acknowledged",
12161225

12171226
// Project Bluebird (Channels) events
12181227
CHANNELS_SPACE_VIEWED: "Channels space viewed",
@@ -1368,6 +1377,7 @@ export type EventPropertyMap = {
13681377
// Subscription events
13691378
[ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN]: UpgradePromptShownProperties;
13701379
[ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED]: UpgradePromptClickedProperties;
1380+
[ANALYTICS_EVENTS.USAGE_BILLING_ANNOUNCEMENT_ACKNOWLEDGED]: UsageBillingAnnouncementAcknowledgedProperties;
13711381
[ANALYTICS_EVENTS.CLOUD_TASK_USAGE_BLOCKED]: CloudTaskUsageBlockedProperties;
13721382
[ANALYTICS_EVENTS.SUBSCRIPTION_STARTED]: SubscriptionStartedProperties;
13731383
[ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED]: SubscriptionCancelledProperties;

packages/shared/src/flags.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
export const BILLING_FLAG = "posthog-code-billing";
22
export const SPEND_ANALYSIS_FLAG = "posthog-code-spend-analysis";
3+
/**
4+
* Launch switch for the one-time usage-based billing announcement: flip at
5+
* cutover, delete once the fleet has acknowledged.
6+
*/
7+
export const USAGE_BILLING_FLAG = "posthog-code-usage-billing";
38
export const EXPERIMENT_SUGGESTIONS_FLAG =
49
"posthog-code-experiment-suggestions";
510
export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks";
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { ArrowSquareOut, CreditCard } from "@phosphor-icons/react";
2+
import { USAGE_BILLING_FLAG } from "@posthog/shared";
3+
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
4+
import { Button, Dialog, Flex, Text } from "@radix-ui/themes";
5+
import { useEffect } from "react";
6+
import { track } from "../../shell/analytics";
7+
import { openExternalUrl } from "../../shell/openExternal";
8+
import { getBillingUrl } from "../../utils/urls";
9+
import { useAuthStateValue } from "../auth/store";
10+
import { useFeatureFlag } from "../feature-flags/useFeatureFlag";
11+
import { useBillingAnnouncementStore } from "./billingAnnouncementStore";
12+
13+
/**
14+
* One-time blocking announcement of the usage-based billing cutover. The
15+
* flag is its launch switch — flip at cutover, delete once the fleet has
16+
* acknowledged.
17+
*/
18+
export function UsageBillingAnnouncementModal() {
19+
const armed = useFeatureFlag(USAGE_BILLING_FLAG);
20+
const acknowledged = useBillingAnnouncementStore((s) => s.acknowledged);
21+
const hasHydrated = useBillingAnnouncementStore((s) => s._hasHydrated);
22+
const acknowledge = useBillingAnnouncementStore((s) => s.acknowledge);
23+
const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
24+
const isLoggedIn = useAuthStateValue((state) => state.currentOrgId !== null);
25+
26+
const isOpen = armed && isLoggedIn && hasHydrated && !acknowledged;
27+
28+
useEffect(() => {
29+
if (isOpen) {
30+
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, {
31+
surface: "billing_announcement",
32+
});
33+
}
34+
}, [isOpen]);
35+
36+
const handleAcknowledge = () => {
37+
track(ANALYTICS_EVENTS.USAGE_BILLING_ANNOUNCEMENT_ACKNOWLEDGED, {
38+
$set: {
39+
code_usage_billing_acknowledged_at: new Date().toISOString(),
40+
},
41+
});
42+
acknowledge();
43+
};
44+
45+
const handleManageBilling = () => {
46+
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, {
47+
surface: "billing_announcement",
48+
});
49+
const billingUrl = getBillingUrl(cloudRegion);
50+
if (billingUrl) openExternalUrl(billingUrl);
51+
};
52+
53+
return (
54+
<Dialog.Root open={isOpen}>
55+
<Dialog.Content
56+
maxWidth="480px"
57+
onInteractOutside={(e) => e.preventDefault()}
58+
onEscapeKeyDown={(e) => e.preventDefault()}
59+
>
60+
<Flex direction="column" gap="3">
61+
<Flex align="center" gap="2">
62+
<CreditCard size={20} weight="bold" color="var(--accent-9)" />
63+
<Dialog.Title className="mb-0">
64+
PostHog Code billing has changed
65+
</Dialog.Title>
66+
</Flex>
67+
<Dialog.Description>
68+
<Text color="gray" className="text-sm">
69+
Seat-based plans are gone. PostHog Code is now usage-based — your
70+
organization pays for AI usage at cost, and you only pay for what
71+
you use.
72+
</Text>
73+
</Dialog.Description>
74+
<Flex direction="column" gap="2" className="text-sm">
75+
<Text>
76+
• Your organization's first <Text weight="medium">$20</Text> of
77+
usage each month is included.
78+
</Text>
79+
<Text>
80+
• Premium models (Claude, GPT) need a payment method on your
81+
organization; an open model stays available on the free tier.
82+
</Text>
83+
<Text>
84+
• Every organization starts with a{" "}
85+
<Text weight="medium">$50/month</Text> spend limit you can raise,
86+
lower, or remove in PostHog billing settings.
87+
</Text>
88+
</Flex>
89+
<Flex justify="end" gap="3" mt="2">
90+
<Button
91+
type="button"
92+
variant="soft"
93+
color="gray"
94+
onClick={handleManageBilling}
95+
>
96+
Manage billing
97+
<ArrowSquareOut size={12} />
98+
</Button>
99+
<Button type="button" onClick={handleAcknowledge}>
100+
Got it
101+
</Button>
102+
</Flex>
103+
</Flex>
104+
</Dialog.Content>
105+
</Dialog.Root>
106+
);
107+
}

packages/ui/src/features/billing/UsageButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export function UsageButton() {
121121
>
122122
<div className="flex items-center justify-between">
123123
<span className="font-medium text-foreground text-xs">
124-
Free plan
124+
Free tier
125125
<Circle
126126
size={4}
127127
weight="fill"
@@ -137,7 +137,7 @@ export function UsageButton() {
137137
className="h-auto p-0"
138138
onClick={() => handleOpenPlan("titlebar_card")}
139139
>
140-
Upgrade
140+
Unlock more
141141
</Button>
142142
</div>
143143
<Progress
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { electronStorage } from "@posthog/ui/shell/rendererStorage";
2+
import { create } from "zustand";
3+
import { persist } from "zustand/middleware";
4+
5+
interface BillingAnnouncementState {
6+
acknowledged: boolean;
7+
// Hydration is async (Electron storage over IPC); the announcement must not
8+
// flash open before a persisted acknowledgment has been read back.
9+
_hasHydrated: boolean;
10+
acknowledge: () => void;
11+
setHasHydrated: (hydrated: boolean) => void;
12+
}
13+
14+
export const useBillingAnnouncementStore = create<BillingAnnouncementState>()(
15+
persist(
16+
(set) => ({
17+
acknowledged: false,
18+
_hasHydrated: false,
19+
acknowledge: () => set({ acknowledged: true }),
20+
setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }),
21+
}),
22+
{
23+
name: "posthog-code-usage-billing-acknowledged",
24+
storage: electronStorage,
25+
partialize: (state) => ({ acknowledged: state.acknowledged }),
26+
onRehydrateStorage: () => (state) => {
27+
state?.setHasHydrated(true);
28+
},
29+
},
30+
),
31+
);
Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
1+
import { isCodeUsageFreeTier } from "@posthog/core/billing/usageDisplay";
12
import type { UsageOutput } from "@posthog/core/usage/schemas";
2-
import { useSeat } from "./useSeat";
33
import { useUsage } from "./useUsage";
44

55
export interface FreeUsageResult {
66
usage: UsageOutput | null;
7-
// True when the user is eligible to see the Free sidebar bar but data
7+
// True when the user is eligible to see the free-tier meter but data
88
// hasn't arrived yet. Distinguishes "show skeleton" from "render nothing".
99
isLoading: boolean;
1010
}
1111

1212
export function useFreeUsage(billingEnabled: boolean): FreeUsageResult {
13-
const { seat, isPro } = useSeat();
14-
const seatLoaded = seat !== null;
15-
const eligible = billingEnabled && seatLoaded && !isPro;
16-
const { usage, isLoading } = useUsage({ enabled: eligible });
13+
const { usage, isLoading } = useUsage({ enabled: billingEnabled });
1714

18-
if (!eligible) return { usage: null, isLoading: false };
15+
if (!billingEnabled) return { usage: null, isLoading: false };
16+
// Only confirmed free-tier orgs have a meaningful free-tier meter —
17+
// subscribed orgs have no per-user caps, and unknown must not render as free.
18+
if (!isCodeUsageFreeTier(usage)) {
19+
return { usage: null, isLoading: usage ? false : isLoading };
20+
}
1921
return { usage: usage ?? null, isLoading };
2022
}

packages/ui/src/features/settings/components/SettingsPanel.tsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import { useAuthStateValue } from "@posthog/ui/features/auth/store";
2525
import { useLogoutMutation } from "@posthog/ui/features/auth/useAuthMutations";
2626
import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
2727
import { getUserInitials } from "@posthog/ui/features/auth/userInitials";
28-
import { useSeat } from "@posthog/ui/features/billing/useSeat";
2928
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
3029
import { closeSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings";
3130
import { AdvancedSettings } from "@posthog/ui/features/settings/sections/AdvancedSettings";
@@ -166,7 +165,6 @@ export function SettingsPanel({
166165
);
167166
const client = useOptionalAuthenticatedClient();
168167
const { data: user } = useCurrentUser({ client });
169-
const { seat, planLabel } = useSeat();
170168
const billingEnabled = useFeatureFlag(BILLING_FLAG);
171169
const { localWorkspaces } = useHostCapabilities();
172170
const logoutMutation = useLogoutMutation();
@@ -234,11 +232,6 @@ export function SettingsPanel({
234232
<Text truncate className="font-medium text-sm">
235233
{user.email}
236234
</Text>
237-
{seat && (
238-
<Text className="text-(--gray-9) text-[13px]">
239-
{planLabel} Plan
240-
</Text>
241-
)}
242235
</Flex>
243236
</Flex>
244237
)}

packages/ui/src/features/settings/sections/AccountSettings.tsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { useAuthStateValue } from "@posthog/ui/features/auth/store";
55
import { useLogoutMutation } from "@posthog/ui/features/auth/useAuthMutations";
66
import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
77
import { getUserInitials } from "@posthog/ui/features/auth/userInitials";
8-
import { useSeat } from "@posthog/ui/features/billing/useSeat";
98
import { Avatar, Badge, Button, Flex, Spinner, Text } from "@radix-ui/themes";
109

1110
export function AccountSettings() {
@@ -19,7 +18,6 @@ export function AccountSettings() {
1918
client,
2019
enabled: isAuthenticated,
2120
});
22-
const { seat, isPro, planLabel } = useSeat();
2321

2422
const handleLogout = () => {
2523
logoutMutation.mutate();
@@ -65,11 +63,6 @@ export function AccountSettings() {
6563
{formatRegionBadge(cloudRegion)}
6664
</Badge>
6765
)}
68-
{seat && (
69-
<Badge size="1" variant="soft" color={isPro ? "orange" : "gray"}>
70-
{planLabel}
71-
</Badge>
72-
)}
7366
</Flex>
7467
</Flex>
7568
<Button

0 commit comments

Comments
 (0)