Skip to content

Commit 5229b7f

Browse files
adboioposthog[bot]
andauthored
fix(billing): explain the merged usage limit as $20 included + org spend limit (#3509)
Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com> Co-authored-by: adboio <23323033+adboio@users.noreply.github.com>
1 parent 7339159 commit 5229b7f

8 files changed

Lines changed: 322 additions & 15 deletions

File tree

apps/code/snapshots.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,26 @@ snapshots:
9696
hash: v1.k4693efd2.b76d39ed8142d4449cb5befcde538bb11a6bffcbed66e81bdc3aa8e2746b7e06.GSIfBsgE-3RYuqlLUpluZ87C6-KjBUaTj2eu_a5elZ8
9797
autoresearch-runtime-stats--with-context-usage--light:
9898
hash: v1.k4693efd2.18889b5eda942cd19b0f7be00a87925d632529ca4111fac180d6339f7d34d1ca.X3s9IXrx2-TdQaJEjf1td3hwvNzeIp3Xq3uQh9Gwt5A
99+
billing-usagemeter--exceeded--dark:
100+
hash: v1.k4693efd2.dc026c5cace4e44c4cc713b95654e278d7bf0439a4862e9ceea39a033974815d.m5Be4dAOwwmt-UlEnhbhrKiym1rlGUiGZ4oyUonUoVg
101+
billing-usagemeter--exceeded--light:
102+
hash: v1.k4693efd2.850bf1e451a18981d9e3be653837dd282c6be289ac9c0826ff6a981ac67e1e38.1GwpfJ_4dUlMO7TXp8AOM5YmJuItGGB50ihKkkeIFgg
103+
billing-usagemeter--free-tier--dark:
104+
hash: v1.k4693efd2.164d54cac22cfa05dbbf25fae1a62e77c496b1981dcaff6b8e1ab07fc802f5e0.Gh75uI1vHbGWAgKun41BYoX6LZBYX_FVI7Qu-rFlDV4
105+
billing-usagemeter--free-tier--light:
106+
hash: v1.k4693efd2.5e220fe66fac4c54b4cb9f5ac6ec1054e0b709374fee2c58fdba782f5b2888df.W5la88YA6uOSc1tg30raKaRy12plPGxXl5o7uowL2gc
107+
billing-usagemeter--subscribed-past-included--dark:
108+
hash: v1.k4693efd2.ac987b7184aaa4f8895d9b75c6b7e1177efcb9a21cca0668caf533ee0d5eaab3.2Mq2GLXV83ebM8LKhTQY8csv6BeskWv5yPu8NSAQ20Y
109+
billing-usagemeter--subscribed-past-included--light:
110+
hash: v1.k4693efd2.0c4a37beaceb37baab57e6da98321b236c20127731fd5eaeb61677de4580c204.fZ2eDGlkEijZjHQyatiHtP39EFYT6EO1Vn_f8Tz7b0E
111+
billing-usagemeter--subscribed-with-breakdown--dark:
112+
hash: v1.k4693efd2.f74f8f76e51ee14e427423e1757859e3f001b13e6a43d1e7310b5b0cbbb570b5.eMwK2csA86oBarnAya99W87MfP6Z6PMfdjwbCmnPrw4
113+
billing-usagemeter--subscribed-with-breakdown--light:
114+
hash: v1.k4693efd2.1335dba8bf16980c987882641619e114584343ec50bb69b3c01e63507a13756b._v7_6tsVMmW3WixSeFN-puHQVeIxf9AAGxFZ8Ni97YQ
115+
billing-usagemeter--zero-spend-limit--dark:
116+
hash: v1.k4693efd2.1d199b2c4bba8034cb18fb5b49866b64a6eb3311591add5f58ec288710dce2e9.FCc9egSaO1Onih417yciSYKNROe8zqDZyyS6xuXCZGw
117+
billing-usagemeter--zero-spend-limit--light:
118+
hash: v1.k4693efd2.b50c18736bbde45fc89f4c0ac7fd616286e5c1ecc2a56910fffca13eaa13d8a2.31z4F0JmvMeR4yqk5pjs_OQ3MypnM2miZI9al4wuc8c
99119
components-permissions-permissionselector--create-new-file--dark:
100120
hash: v1.k4693efd2.c54203a4e636b83b3d24d7ed9c4ace8659db87cd8f231d9dc2ecc03320e31646.epDm7LebiLzlp0uuZBrE-Obt_anAn0xsE8bHFnm5vos
101121
components-permissions-permissionselector--create-new-file--light:

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { describe, expect, it } from "vitest";
22
import type { UsageOutput } from "../usage/schemas";
33
import {
4+
codeOrgSpendLimitUsd,
45
codeUsageMeter,
56
formatResetTime,
7+
formatUsageBreakdown,
68
formatUsdAmount,
79
isCodeUsageFreeTier,
810
isUsageExceeded,
@@ -92,9 +94,32 @@ describe("codeUsageMeter", () => {
9294
percent: 25,
9395
exceeded: false,
9496
resetAt: "2026-06-01T00:00:00.000Z",
97+
breakdown: { includedUsd: 20, spendLimitUsd: 30 },
9598
});
9699
});
97100

101+
it("splits a default-settings subscribed limit into $20 included + $50 spend limit", () => {
102+
const meter = codeUsageMeter({
103+
...makeUsage(),
104+
code_usage_subscribed: true,
105+
ai_credits: { exhausted: false, used_usd: 5, limit_usd: 70 },
106+
});
107+
expect(meter).toMatchObject({
108+
kind: "dollars",
109+
limitUsd: 70,
110+
breakdown: { includedUsd: 20, spendLimitUsd: 50 },
111+
});
112+
});
113+
114+
it("keeps a free org's dollars meter breakdown-free — its limit is just the allowance", () => {
115+
const meter = codeUsageMeter({
116+
...makeUsage(),
117+
code_usage_subscribed: false,
118+
ai_credits: { exhausted: false, used_usd: 5, limit_usd: 20 },
119+
});
120+
expect(meter).toMatchObject({ kind: "dollars", breakdown: null });
121+
});
122+
98123
it("marks the dollars meter exceeded from the org bucket and falls back to the sustained reset", () => {
99124
const meter = codeUsageMeter({
100125
...makeUsage(),
@@ -134,6 +159,59 @@ describe("codeUsageMeter", () => {
134159
});
135160
});
136161

162+
describe("codeOrgSpendLimitUsd", () => {
163+
const subscribedWithLimit = (limitUsd: number | null) => ({
164+
code_usage_subscribed: true,
165+
ai_credits: { exhausted: false, used_usd: 0, limit_usd: limitUsd },
166+
});
167+
168+
it.each([
169+
["default settings", 70, 50],
170+
["custom limit", 120.5, 100.5],
171+
["a $0 spend limit is a real answer", 20, 0],
172+
])("recovers the configured limit with %s", (_name, limitUsd, expected) => {
173+
expect(codeOrgSpendLimitUsd(subscribedWithLimit(limitUsd))).toBe(expected);
174+
});
175+
176+
it("returns null when the merged limit is below the allowance", () => {
177+
expect(codeOrgSpendLimitUsd(subscribedWithLimit(15))).toBeNull();
178+
});
179+
180+
it("returns null without a limit number", () => {
181+
expect(codeOrgSpendLimitUsd(subscribedWithLimit(null))).toBeNull();
182+
expect(
183+
codeOrgSpendLimitUsd({
184+
code_usage_subscribed: true,
185+
ai_credits: undefined,
186+
}),
187+
).toBeNull();
188+
});
189+
190+
it("returns null for free, unknown, or missing orgs", () => {
191+
expect(
192+
codeOrgSpendLimitUsd({
193+
code_usage_subscribed: false,
194+
ai_credits: { exhausted: false, used_usd: 0, limit_usd: 20 },
195+
}),
196+
).toBeNull();
197+
expect(
198+
codeOrgSpendLimitUsd({
199+
code_usage_subscribed: undefined,
200+
ai_credits: { exhausted: false, used_usd: 0, limit_usd: 70 },
201+
}),
202+
).toBeNull();
203+
expect(codeOrgSpendLimitUsd(null)).toBeNull();
204+
});
205+
});
206+
207+
describe("formatUsageBreakdown", () => {
208+
it("phrases the merged limit as included + spend limit", () => {
209+
expect(formatUsageBreakdown({ includedUsd: 20, spendLimitUsd: 50 })).toBe(
210+
"$20 included + $50 org spend limit",
211+
);
212+
});
213+
});
214+
137215
describe("formatUsdAmount", () => {
138216
it.each([
139217
[50, "$50"],

packages/core/src/billing/usageDisplay.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
11
import type { UsageBucket, UsageOutput } from "../usage/schemas";
22

3+
export const CODE_INCLUDED_USAGE_USD = 20;
4+
35
/** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */
46
export function isCodeUsageFreeTier(
57
usage: Pick<UsageOutput, "code_usage_subscribed"> | null | undefined,
68
): boolean {
79
return usage?.code_usage_subscribed === false;
810
}
911

12+
export function codeOrgSpendLimitUsd(
13+
usage:
14+
| Pick<UsageOutput, "code_usage_subscribed" | "ai_credits">
15+
| null
16+
| undefined,
17+
): number | null {
18+
if (usage?.code_usage_subscribed !== true) return null;
19+
const limitUsd = usage.ai_credits?.limit_usd;
20+
if (limitUsd == null || limitUsd < CODE_INCLUDED_USAGE_USD) return null;
21+
return Math.round((limitUsd - CODE_INCLUDED_USAGE_USD) * 100) / 100;
22+
}
23+
24+
export interface CodeUsageBreakdown {
25+
includedUsd: number;
26+
spendLimitUsd: number;
27+
}
28+
1029
export type CodeUsageMeter =
1130
| {
1231
kind: "dollars";
@@ -15,6 +34,7 @@ export type CodeUsageMeter =
1534
percent: number;
1635
exceeded: boolean;
1736
resetAt: string;
37+
breakdown: CodeUsageBreakdown | null;
1838
}
1939
| { kind: "bucket"; bucket: UsageBucket }
2040
| { kind: "hidden" };
@@ -32,13 +52,18 @@ export function codeUsageMeter(
3252
const usedUsd = usage.ai_credits?.used_usd;
3353
const limitUsd = usage.ai_credits?.limit_usd;
3454
if (usedUsd != null && limitUsd != null && limitUsd > 0) {
55+
const spendLimitUsd = codeOrgSpendLimitUsd(usage);
3556
return {
3657
kind: "dollars",
3758
usedUsd,
3859
limitUsd,
3960
percent: Math.min(100, Math.round((usedUsd / limitUsd) * 100)),
4061
exceeded: usage.ai_credits?.exhausted === true,
4162
resetAt: usage.billing_period_end ?? usage.sustained.reset_at,
63+
breakdown:
64+
spendLimitUsd != null
65+
? { includedUsd: CODE_INCLUDED_USAGE_USD, spendLimitUsd }
66+
: null,
4267
};
4368
}
4469
if (isCodeUsageFreeTier(usage)) {
@@ -51,6 +76,10 @@ export function formatUsdAmount(amount: number): string {
5176
return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`;
5277
}
5378

79+
export function formatUsageBreakdown(breakdown: CodeUsageBreakdown): string {
80+
return `${formatUsdAmount(breakdown.includedUsd)} included + ${formatUsdAmount(breakdown.spendLimitUsd)} org spend limit`;
81+
}
82+
5483
export function isUsageExceeded(usage: UsageOutput): boolean {
5584
return (
5685
usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded

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

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { ArrowSquareOut, CreditCard } from "@phosphor-icons/react";
2-
import { formatUsdAmount } from "@posthog/core/billing/usageDisplay";
2+
import {
3+
codeOrgSpendLimitUsd,
4+
formatUsdAmount,
5+
} from "@posthog/core/billing/usageDisplay";
36
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
47
import { Button, Dialog, Flex, Text } from "@radix-ui/themes";
58
import { useEffect } from "react";
@@ -22,12 +25,7 @@ export function UsageBillingAnnouncementModal() {
2225
const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
2326
const { usage } = useUsage({ enabled: isOpen });
2427

25-
// A free org's limit_usd is its included allocation (the first bullet's
26-
// $20), not a spend limit — the org-specific line is for subscribed orgs.
27-
const limitUsd =
28-
usage?.code_usage_subscribed === true
29-
? (usage.ai_credits?.limit_usd ?? null)
30-
: null;
28+
const spendLimitUsd = codeOrgSpendLimitUsd(usage);
3129

3230
useEffect(() => {
3331
if (isOpen) {
@@ -83,10 +81,10 @@ export function UsageBillingAnnouncementModal() {
8381
• Premium models need a payment method; an open model stays free.
8482
</Text>
8583
<Text color="gray">
86-
{limitUsd != null ? (
84+
{spendLimitUsd != null ? (
8785
<>
8886
• Your organization's spend limit is{" "}
89-
<Text weight="medium">{`${formatUsdAmount(limitUsd)}/month`}</Text>{" "}
87+
<Text weight="medium">{`${formatUsdAmount(spendLimitUsd)}/month`}</Text>{" "}
9088
— adjust it any time in billing settings.
9189
</>
9290
) : (

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Circle } from "@phosphor-icons/react";
22
import {
33
formatResetTime,
4+
formatUsageBreakdown,
45
formatUsdAmount,
56
} from "@posthog/core/billing/usageDisplay";
67
import {
@@ -70,6 +71,10 @@ export function UsageButton() {
7071
const resetLabel = formatResetTime(
7172
meter.kind === "dollars" ? meter.resetAt : meter.bucket.reset_at,
7273
);
74+
const breakdownLabel =
75+
meter.kind === "dollars" && meter.breakdown
76+
? formatUsageBreakdown(meter.breakdown)
77+
: null;
7378

7479
// Upgrade-prompt analytics only apply to free-tier orgs — a subscribed
7580
// org's meter is not an upgrade prompt.
@@ -158,7 +163,7 @@ export function UsageButton() {
158163
variant={blocked ? "destructive" : "default"}
159164
/>
160165
<div className="font-normal text-[11px] text-muted-foreground">
161-
{resetLabel}
166+
{breakdownLabel ? `${breakdownLabel} · ${resetLabel}` : resetLabel}
162167
</div>
163168
</PopoverContent>
164169
</Popover>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { UsageMeter } from "@posthog/ui/features/billing/UsageMeter";
2+
import type { Meta, StoryObj } from "@storybook/react-vite";
3+
4+
const meta: Meta<typeof UsageMeter> = {
5+
title: "Billing/UsageMeter",
6+
component: UsageMeter,
7+
decorators: [
8+
(Story) => (
9+
<div style={{ maxWidth: 560 }}>
10+
<Story />
11+
</div>
12+
),
13+
],
14+
};
15+
16+
export default meta;
17+
type Story = StoryObj<typeof UsageMeter>;
18+
19+
// A subscribed org on default settings: the $70 limit renders as two
20+
// segments — the $20 included allowance (green) and the $50 default spend
21+
// limit (accent) — with a dot legend naming each amount. Usage still inside
22+
// the included allowance only fills the green segment.
23+
export const SubscribedWithBreakdown: Story = {
24+
args: {
25+
label: "Usage this period",
26+
percent: 18,
27+
valueLabel: "$12.40 of $70",
28+
detail: "Resets Jul 31 at 2:00 PM PDT",
29+
breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 12.4 },
30+
},
31+
};
32+
33+
// Past the allowance: the green segment is full and billable usage fills the
34+
// accent segment.
35+
export const SubscribedPastIncluded: Story = {
36+
args: {
37+
label: "Usage this period",
38+
percent: 66,
39+
valueLabel: "$46.20 of $70",
40+
detail: "Resets Jul 31 at 2:00 PM PDT",
41+
breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 46.2 },
42+
},
43+
};
44+
45+
// A subscribed org that set its spend limit to $0: only the included segment
46+
// (and its legend entry) renders.
47+
export const ZeroSpendLimit: Story = {
48+
args: {
49+
label: "Usage this period",
50+
percent: 65,
51+
valueLabel: "$13 of $20",
52+
detail: "Resets Jul 31 at 2:00 PM PDT",
53+
breakdown: { includedUsd: 20, spendLimitUsd: 0, usedUsd: 13 },
54+
},
55+
};
56+
57+
// Free tier has no breakdown — its limit IS the allowance, so the plain
58+
// single-track bar renders.
59+
export const FreeTier: Story = {
60+
args: {
61+
label: "Monthly free usage",
62+
percent: 62,
63+
valueLabel: "$12.40 of $20 included",
64+
detail: "Resets Jul 31 at 2:00 PM PDT",
65+
},
66+
};
67+
68+
export const Exceeded: Story = {
69+
args: {
70+
label: "Usage this period",
71+
percent: 100,
72+
valueLabel: "$70 of $70",
73+
detail: "Limit exceeded. Resets Jul 31 at 2:00 PM PDT",
74+
breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 70 },
75+
color: "red",
76+
},
77+
};

0 commit comments

Comments
 (0)