Skip to content

Commit 927acb0

Browse files
committed
fix(billing): explain the merged usage limit as included allowance + spend limit
A subscribed org's ai_credits.limit_usd arrives from the gateway as one merged number: the $20 included monthly allowance plus the configured spend limit. The plans-page meter and title-bar card rendered it raw, so an org on the default limit saw a total matching nothing it configured, and the billing announcement modal misquoted the merged number as the org's spend limit. Derive the breakdown in codeUsageMeter (via codeOrgSpendLimitUsd), spell it out under both meters ("$20 included + $50 org spend limit"), notch the plans-page bar where the included allowance ends, and quote the recovered spend limit in the announcement modal. Generated-By: PostHog Code Task-Id: b24f71e2-3751-4a6b-b5d8-a24147c4c067
1 parent 9beff6e commit 927acb0

7 files changed

Lines changed: 247 additions & 16 deletions

File tree

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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,49 @@
11
import type { UsageBucket, UsageOutput } from "../usage/schemas";
22

3+
/**
4+
* The monthly usage allowance included for every org under Code usage
5+
* billing — the "first $20 each month is included" from the billing product
6+
* config. The gateway has no field for it: a subscribed org's
7+
* `ai_credits.limit_usd` arrives as one merged number (allowance + configured
8+
* spend limit), so display code peels the allowance back off with this
9+
* constant. If billing ever changes the allowance or starts sending a
10+
* breakdown, this constant (and the copy quoting $20) is the seam.
11+
*/
12+
export const CODE_INCLUDED_USAGE_USD = 20;
13+
314
/** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */
415
export function isCodeUsageFreeTier(
516
usage: Pick<UsageOutput, "code_usage_subscribed"> | null | undefined,
617
): boolean {
718
return usage?.code_usage_subscribed === false;
819
}
920

21+
/**
22+
* The spend limit the org actually configured in billing settings ($50 by
23+
* default), recovered from the merged `limit_usd`. Null when it can't be
24+
* recovered: unconfirmed/free orgs (their limit IS the allowance), missing
25+
* numbers, or a merged limit below the allowance. Zero is a real answer — a
26+
* subscribed org whose merged limit equals the allowance set its spend limit
27+
* to $0.
28+
*/
29+
export function codeOrgSpendLimitUsd(
30+
usage:
31+
| Pick<UsageOutput, "code_usage_subscribed" | "ai_credits">
32+
| null
33+
| undefined,
34+
): number | null {
35+
if (usage?.code_usage_subscribed !== true) return null;
36+
const limitUsd = usage.ai_credits?.limit_usd;
37+
if (limitUsd == null || limitUsd < CODE_INCLUDED_USAGE_USD) return null;
38+
// Round away float dust — the wire amounts are already cent-rounded.
39+
return Math.round((limitUsd - CODE_INCLUDED_USAGE_USD) * 100) / 100;
40+
}
41+
42+
export interface CodeUsageBreakdown {
43+
includedUsd: number;
44+
spendLimitUsd: number;
45+
}
46+
1047
export type CodeUsageMeter =
1148
| {
1249
kind: "dollars";
@@ -15,6 +52,10 @@ export type CodeUsageMeter =
1552
percent: number;
1653
exceeded: boolean;
1754
resetAt: string;
55+
// How the merged limit decomposes for a subscribed org, so the meter
56+
// can explain "$70" as $20 included + $50 spend limit. Null when the
57+
// split is unknown (free tier, or a limit below the allowance).
58+
breakdown: CodeUsageBreakdown | null;
1859
}
1960
| { kind: "bucket"; bucket: UsageBucket }
2061
| { kind: "hidden" };
@@ -32,13 +73,18 @@ export function codeUsageMeter(
3273
const usedUsd = usage.ai_credits?.used_usd;
3374
const limitUsd = usage.ai_credits?.limit_usd;
3475
if (usedUsd != null && limitUsd != null && limitUsd > 0) {
76+
const spendLimitUsd = codeOrgSpendLimitUsd(usage);
3577
return {
3678
kind: "dollars",
3779
usedUsd,
3880
limitUsd,
3981
percent: Math.min(100, Math.round((usedUsd / limitUsd) * 100)),
4082
exceeded: usage.ai_credits?.exhausted === true,
4183
resetAt: usage.billing_period_end ?? usage.sustained.reset_at,
84+
breakdown:
85+
spendLimitUsd != null
86+
? { includedUsd: CODE_INCLUDED_USAGE_USD, spendLimitUsd }
87+
: null,
4288
};
4389
}
4490
if (isCodeUsageFreeTier(usage)) {
@@ -51,6 +97,11 @@ export function formatUsdAmount(amount: number): string {
5197
return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`;
5298
}
5399

100+
/** One shared phrasing for the merged-limit explanation, e.g. "$20 included + $50 org spend limit". */
101+
export function formatUsageBreakdown(breakdown: CodeUsageBreakdown): string {
102+
return `${formatUsdAmount(breakdown.includedUsd)} included + ${formatUsdAmount(breakdown.spendLimitUsd)} org spend limit`;
103+
}
104+
54105
export function isUsageExceeded(usage: UsageOutput): boolean {
55106
return (
56107
usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded

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

Lines changed: 11 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,11 @@ 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+
// A subscribed org's limit_usd merges the included $20 with the configured
29+
// spend limit — quote the recovered spend limit, the number the org set.
30+
// Free orgs (whose limit_usd is just the first bullet's $20) get the
31+
// default-limit line instead.
32+
const spendLimitUsd = codeOrgSpendLimitUsd(usage);
3133

3234
useEffect(() => {
3335
if (isOpen) {
@@ -83,10 +85,10 @@ export function UsageBillingAnnouncementModal() {
8385
• Premium models need a payment method; an open model stays free.
8486
</Text>
8587
<Text color="gray">
86-
{limitUsd != null ? (
88+
{spendLimitUsd != null ? (
8789
<>
8890
• Your organization's spend limit is{" "}
89-
<Text weight="medium">{`${formatUsdAmount(limitUsd)}/month`}</Text>{" "}
91+
<Text weight="medium">{`${formatUsdAmount(spendLimitUsd)}/month`}</Text>{" "}
9092
— adjust it any time in billing settings.
9193
</>
9294
) : (

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

Lines changed: 9 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,13 @@ export function UsageButton() {
7071
const resetLabel = formatResetTime(
7172
meter.kind === "dollars" ? meter.resetAt : meter.bucket.reset_at,
7273
);
74+
// The subscribed meter's limit is a merged number (included allowance +
75+
// configured spend limit); without the split, "$70" matches nothing the
76+
// user ever set.
77+
const breakdownLabel =
78+
meter.kind === "dollars" && meter.breakdown
79+
? formatUsageBreakdown(meter.breakdown)
80+
: null;
7381

7482
// Upgrade-prompt analytics only apply to free-tier orgs — a subscribed
7583
// org's meter is not an upgrade prompt.
@@ -158,7 +166,7 @@ export function UsageButton() {
158166
variant={blocked ? "destructive" : "default"}
159167
/>
160168
<div className="font-normal text-[11px] text-muted-foreground">
161-
{resetLabel}
169+
{breakdownLabel ? `${breakdownLabel} · ${resetLabel}` : resetLabel}
162170
</div>
163171
</PopoverContent>
164172
</Popover>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 is $20 included
20+
// allowance + the $50 default spend limit, spelled out in the detail line
21+
// with the notch marking where the included allowance ends.
22+
export const SubscribedWithBreakdown: Story = {
23+
args: {
24+
label: "Usage this period",
25+
percent: 18,
26+
valueLabel: "$12.40 of $70",
27+
detail: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT",
28+
markerPercent: (20 / 70) * 100,
29+
},
30+
};
31+
32+
export const SubscribedPastIncluded: Story = {
33+
args: {
34+
label: "Usage this period",
35+
percent: 66,
36+
valueLabel: "$46.20 of $70",
37+
detail: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT",
38+
markerPercent: (20 / 70) * 100,
39+
},
40+
};
41+
42+
export const FreeTier: Story = {
43+
args: {
44+
label: "Monthly free usage",
45+
percent: 62,
46+
valueLabel: "$12.40 of $20 included",
47+
detail: "Resets Jul 31 at 2:00 PM PDT",
48+
},
49+
};
50+
51+
export const Exceeded: Story = {
52+
args: {
53+
label: "Usage this period",
54+
percent: 100,
55+
valueLabel: "$70 of $70",
56+
detail:
57+
"Limit exceeded. $20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT",
58+
markerPercent: (20 / 70) * 100,
59+
color: "red",
60+
},
61+
};

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

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ interface UsageMeterProps {
66
valueLabel: string;
77
detail: string;
88
color?: "red";
9+
// Where a boundary inside the limit falls (e.g. the end of the included
10+
// allowance), as a percent of the bar. Rendered as a notch over the track.
11+
markerPercent?: number;
912
}
1013

1114
export function UsageMeter({
@@ -14,8 +17,11 @@ export function UsageMeter({
1417
valueLabel,
1518
detail,
1619
color,
20+
markerPercent,
1721
}: UsageMeterProps) {
1822
const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)";
23+
const showMarker =
24+
markerPercent != null && markerPercent > 0 && markerPercent < 100;
1925

2026
return (
2127
<Flex
@@ -31,11 +37,20 @@ export function UsageMeter({
3137
<Text className="font-medium text-sm">{label}</Text>
3238
<Text className="font-medium text-sm">{valueLabel}</Text>
3339
</Flex>
34-
<Progress
35-
value={percent}
36-
size="2"
37-
color={color === "red" ? "red" : undefined}
38-
/>
40+
<div className="relative">
41+
<Progress
42+
value={percent}
43+
size="2"
44+
color={color === "red" ? "red" : undefined}
45+
/>
46+
{showMarker && (
47+
<div
48+
aria-hidden
49+
className="absolute inset-y-0 w-px bg-(--gray-1)"
50+
style={{ left: `${markerPercent}%` }}
51+
/>
52+
)}
53+
</div>
3954
<Text className="text-(--gray-9) text-[13px]">{detail}</Text>
4055
</Flex>
4156
);

0 commit comments

Comments
 (0)