Skip to content

Commit dc90acd

Browse files
committed
feat(billing): dollar usage surfaces off the org's billing numbers
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
1 parent 45ca3dc commit dc90acd

8 files changed

Lines changed: 223 additions & 66 deletions

File tree

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { describe, expect, it } from "vitest";
22
import type { UsageOutput } from "../usage/schemas";
33
import {
4+
codeUsageMeter,
45
formatResetTime,
6+
formatUsdAmount,
57
isCodeUsageFreeTier,
68
isUsageExceeded,
79
} from "./usageDisplay";
@@ -75,6 +77,74 @@ describe("isCodeUsageFreeTier", () => {
7577
});
7678
});
7779

80+
describe("codeUsageMeter", () => {
81+
it("prefers billing's org dollars when both numbers are present", () => {
82+
const meter = codeUsageMeter({
83+
...makeUsage(),
84+
code_usage_subscribed: true,
85+
ai_credits: { exhausted: false, used_usd: 12.4, limit_usd: 50 },
86+
billing_period_end: "2026-06-01T00:00:00.000Z",
87+
});
88+
expect(meter).toEqual({
89+
kind: "dollars",
90+
usedUsd: 12.4,
91+
limitUsd: 50,
92+
percent: 25,
93+
exceeded: false,
94+
resetAt: "2026-06-01T00:00:00.000Z",
95+
});
96+
});
97+
98+
it("marks the dollars meter exceeded from the org bucket and falls back to the sustained reset", () => {
99+
const meter = codeUsageMeter({
100+
...makeUsage(),
101+
code_usage_subscribed: false,
102+
ai_credits: { exhausted: true, used_usd: 20, limit_usd: 20 },
103+
});
104+
expect(meter).toMatchObject({
105+
kind: "dollars",
106+
percent: 100,
107+
exceeded: true,
108+
resetAt: "2026-05-01T13:00:00.000Z",
109+
});
110+
});
111+
112+
it.each([
113+
["missing numbers", { exhausted: false }],
114+
["null numbers", { exhausted: false, used_usd: null, limit_usd: null }],
115+
["a zero limit", { exhausted: false, used_usd: 0, limit_usd: 0 }],
116+
])("falls back to the free-tier valve bucket with %s", (_name, aiCredits) => {
117+
const usage: UsageOutput = {
118+
...makeUsage(),
119+
code_usage_subscribed: false,
120+
ai_credits: aiCredits,
121+
};
122+
expect(codeUsageMeter(usage)).toEqual({
123+
kind: "bucket",
124+
bucket: usage.sustained,
125+
});
126+
});
127+
128+
it("hides the meter for a subscribed or unknown org without dollars", () => {
129+
expect(
130+
codeUsageMeter({ ...makeUsage(), code_usage_subscribed: true }),
131+
).toEqual({ kind: "hidden" });
132+
expect(codeUsageMeter(makeUsage())).toEqual({ kind: "hidden" });
133+
expect(codeUsageMeter(null)).toEqual({ kind: "hidden" });
134+
});
135+
});
136+
137+
describe("formatUsdAmount", () => {
138+
it.each([
139+
[50, "$50"],
140+
[12.4, "$12.40"],
141+
[0.5, "$0.50"],
142+
[0, "$0"],
143+
])("formats %s as %s", (amount, expected) => {
144+
expect(formatUsdAmount(amount)).toBe(expected);
145+
});
146+
});
147+
78148
describe("formatResetTime", () => {
79149
const NOW = Date.parse("2026-05-01T12:00:00.000Z");
80150
const isoAt = (msFromNow: number) => new Date(NOW + msFromNow).toISOString();

packages/core/src/billing/usageDisplay.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { UsageOutput } from "../usage/schemas";
1+
import type { UsageBucket, UsageOutput } from "../usage/schemas";
22

33
/** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */
44
export function isCodeUsageFreeTier(
@@ -7,6 +7,50 @@ export function isCodeUsageFreeTier(
77
return usage?.code_usage_subscribed === false;
88
}
99

10+
export type CodeUsageMeter =
11+
| {
12+
kind: "dollars";
13+
usedUsd: number;
14+
limitUsd: number;
15+
percent: number;
16+
exceeded: boolean;
17+
resetAt: string;
18+
}
19+
| { kind: "bucket"; bucket: UsageBucket }
20+
| { kind: "hidden" };
21+
22+
/**
23+
* What the usage meter should show. Billing's org-level dollars win when
24+
* present; a free-tier org without them falls back to its per-user valve
25+
* bucket; anything else shows nothing — per-user valve percentages are
26+
* meaningless for a subscribed org, and unknown must not render as free.
27+
*/
28+
export function codeUsageMeter(
29+
usage: UsageOutput | null | undefined,
30+
): CodeUsageMeter {
31+
if (!usage) return { kind: "hidden" };
32+
const usedUsd = usage.ai_credits?.used_usd;
33+
const limitUsd = usage.ai_credits?.limit_usd;
34+
if (usedUsd != null && limitUsd != null && limitUsd > 0) {
35+
return {
36+
kind: "dollars",
37+
usedUsd,
38+
limitUsd,
39+
percent: Math.min(100, Math.round((usedUsd / limitUsd) * 100)),
40+
exceeded: usage.ai_credits?.exhausted === true,
41+
resetAt: usage.billing_period_end ?? usage.sustained.reset_at,
42+
};
43+
}
44+
if (isCodeUsageFreeTier(usage)) {
45+
return { kind: "bucket", bucket: usage.sustained };
46+
}
47+
return { kind: "hidden" };
48+
}
49+
50+
export function formatUsdAmount(amount: number): string {
51+
return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`;
52+
}
53+
1054
export function isUsageExceeded(usage: UsageOutput): boolean {
1155
return (
1256
usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ArrowSquareOut, CreditCard } from "@phosphor-icons/react";
2+
import { formatUsdAmount } from "@posthog/core/billing/usageDisplay";
23
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
34
import { Button, Dialog, Flex, Text } from "@radix-ui/themes";
45
import { useEffect } from "react";
@@ -85,9 +86,7 @@ export function UsageBillingAnnouncementModal() {
8586
{limitUsd != null ? (
8687
<>
8788
• Your organization's spend limit is{" "}
88-
<Text weight="medium">
89-
{`$${Number.isInteger(limitUsd) ? limitUsd : limitUsd.toFixed(2)}/month`}
90-
</Text>{" "}
89+
<Text weight="medium">{`${formatUsdAmount(limitUsd)}/month`}</Text>{" "}
9190
— adjust it any time in billing settings.
9291
</>
9392
) : (

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

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Circle } from "@phosphor-icons/react";
22
import {
33
formatResetTime,
4-
isUsageExceeded,
4+
formatUsdAmount,
55
} from "@posthog/core/billing/usageDisplay";
66
import {
77
Button,
@@ -23,19 +23,19 @@ import {
2323
openSettings,
2424
prepareSettingsPage,
2525
} from "../settings/hooks/useOpenSettings";
26-
import { useFreeUsage } from "./useFreeUsage";
26+
import { useUsageMeter } from "./useUsageMeter";
2727

2828
// Title-bar usage entry point (replaces the old sidebar usage bar): a compact
29-
// "Usage: N%" button whose hover card carries the full plan card — plan name,
30-
// progress bar, reset time, and the Upgrade action. Built on quill's Popover
29+
// usage button whose hover card carries the full plan card — plan name,
30+
// progress bar, reset time, and the plan action. Built on quill's Popover
3131
// with `openOnHover` on the trigger, so it behaves as a hover card (Base UI
32-
// keeps it open while the pointer travels into the card to click Upgrade).
32+
// keeps it open while the pointer travels into the card to click the action).
3333
// The card body styles with quill tokens (foreground/muted-foreground/primary,
3434
// quill Progress) — radix scale classes don't resolve inside the data-quill
3535
// popover portal.
3636
export function UsageButton() {
3737
const billingEnabled = useFeatureFlag(BILLING_FLAG);
38-
const { usage, isLoading } = useFreeUsage(billingEnabled);
38+
const { meter, freeTier, blocked, isLoading } = useUsageMeter(billingEnabled);
3939
// Controlled so the trigger click can close the card before navigating to
4040
// settings — uncontrolled, the same click would also toggle the popover open
4141
// over the settings view. Hover open/close still flows through onOpenChange.
@@ -45,7 +45,7 @@ export function UsageButton() {
4545

4646
// Same-size placeholder while usage loads, so the button doesn't pop in and
4747
// shift the PostHog Web button after boot.
48-
if (!usage) {
48+
if (meter.kind === "hidden") {
4949
if (!isLoading) return null;
5050
return (
5151
<Button variant="outline" size="sm" disabled aria-hidden>
@@ -54,18 +54,27 @@ export function UsageButton() {
5454
);
5555
}
5656

57-
const exceeded = isUsageExceeded(usage);
58-
const dominant =
59-
usage.sustained.used_percent >= usage.burst.used_percent
60-
? usage.sustained
61-
: usage.burst;
62-
const usagePercent = Math.min(Math.round(dominant.used_percent), 100);
63-
const resetLabel = formatResetTime(dominant.reset_at);
57+
const percent =
58+
meter.kind === "dollars"
59+
? meter.percent
60+
: Math.min(Math.round(meter.bucket.used_percent), 100);
61+
const buttonLabel = blocked
62+
? "Usage: limit reached"
63+
: meter.kind === "dollars"
64+
? `Usage: ${formatUsdAmount(meter.usedUsd)}`
65+
: `Usage: ${percent}%`;
66+
const amountLabel =
67+
meter.kind === "dollars"
68+
? `${formatUsdAmount(meter.usedUsd)} of ${formatUsdAmount(meter.limitUsd)} used`
69+
: `${percent}% used`;
70+
const resetLabel = formatResetTime(
71+
meter.kind === "dollars" ? meter.resetAt : meter.bucket.reset_at,
72+
);
6473

74+
// Upgrade-prompt analytics only apply to free-tier orgs — a subscribed
75+
// org's meter is not an upgrade prompt.
6576
const handleOpenChange = (nextOpen: boolean) => {
66-
// The hover card has real impressions now (the old sidebar bar was
67-
// always-visible); count each open as a shown upgrade prompt.
68-
if (nextOpen && !open) {
77+
if (nextOpen && !open && freeTier) {
6978
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, {
7079
surface: "titlebar_card",
7180
});
@@ -74,7 +83,9 @@ export function UsageButton() {
7483
};
7584

7685
const handleOpenPlan = (surface: UpgradePromptClickedSurface) => {
77-
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface });
86+
if (freeTier) {
87+
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface });
88+
}
7889
setOpen(false);
7990
openSettings("plan-usage");
8091
};
@@ -84,7 +95,9 @@ export function UsageButton() {
8495
// openSettings would have done — tracking, closing the card, and resetting
8596
// the settings-page store so no stale context/one-shot action leaks in.
8697
const handleTriggerClick = () => {
87-
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface: "titlebar" });
98+
if (freeTier) {
99+
track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface: "titlebar" });
100+
}
88101
setOpen(false);
89102
prepareSettingsPage();
90103
};
@@ -107,7 +120,7 @@ export function UsageButton() {
107120
/>
108121
}
109122
>
110-
{exceeded ? "Usage: limit reached" : `Usage: ${usagePercent}%`}
123+
{buttonLabel}
111124
</Button>
112125
}
113126
/>
@@ -121,14 +134,14 @@ export function UsageButton() {
121134
>
122135
<div className="flex items-center justify-between">
123136
<span className="font-medium text-foreground text-xs">
124-
Free tier
137+
{freeTier ? "Free tier" : "Usage-based billing"}
125138
<Circle
126139
size={4}
127140
weight="fill"
128141
className="mx-1.5 inline text-muted-foreground"
129142
/>
130143
<span className="font-normal text-muted-foreground">
131-
{exceeded ? "Limit reached" : `${usagePercent}% used`}
144+
{blocked ? "Limit reached" : amountLabel}
132145
</span>
133146
</span>
134147
<Button
@@ -137,12 +150,12 @@ export function UsageButton() {
137150
className="h-auto p-0"
138151
onClick={() => handleOpenPlan("titlebar_card")}
139152
>
140-
Unlock more
153+
{freeTier ? "Unlock more" : "View usage"}
141154
</Button>
142155
</div>
143156
<Progress
144-
value={usagePercent}
145-
variant={exceeded ? "destructive" : "default"}
157+
value={percent}
158+
variant={blocked ? "destructive" : "default"}
146159
/>
147160
<div className="font-normal text-[11px] text-muted-foreground">
148161
{resetLabel}
Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
import { formatResetTime } from "@posthog/core/billing/usageDisplay";
2-
import type { UsageBucket } from "@posthog/core/usage/schemas";
31
import { Flex, Progress, Text } from "@radix-ui/themes";
42

53
interface UsageMeterProps {
64
label: string;
7-
bucket: UsageBucket;
5+
percent: number;
6+
valueLabel: string;
7+
detail: string;
88
color?: "red";
99
}
1010

11-
export function UsageMeter({ label, bucket, color }: UsageMeterProps) {
12-
const percentage = bucket.used_percent;
13-
11+
export function UsageMeter({
12+
label,
13+
percent,
14+
valueLabel,
15+
detail,
16+
color,
17+
}: UsageMeterProps) {
1418
const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)";
1519

1620
return (
@@ -25,16 +29,14 @@ export function UsageMeter({ label, bucket, color }: UsageMeterProps) {
2529
>
2630
<Flex align="center" justify="between">
2731
<Text className="font-medium text-sm">{label}</Text>
28-
<Text className="font-medium text-sm">{percentage.toFixed(2)}%</Text>
32+
<Text className="font-medium text-sm">{valueLabel}</Text>
2933
</Flex>
3034
<Progress
31-
value={percentage}
35+
value={percent}
3236
size="2"
3337
color={color === "red" ? "red" : undefined}
3438
/>
35-
<Text className="text-(--gray-9) text-[13px]">
36-
{`${bucket.exceeded ? "Limit exceeded. " : ""}${formatResetTime(bucket.reset_at)}`}
37-
</Text>
39+
<Text className="text-(--gray-9) text-[13px]">{detail}</Text>
3840
</Flex>
3941
);
4042
}

packages/ui/src/features/billing/useFreeUsage.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.

0 commit comments

Comments
 (0)