Skip to content

Commit e6485ac

Browse files
authored
feat(billing): usage-based billing surfaces — plans page, titlebar, announcement (#3487)
1 parent 092903f commit e6485ac

24 files changed

Lines changed: 733 additions & 529 deletions

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

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

511
function makeUsage(
612
overrides: Partial<{
@@ -53,6 +59,92 @@ describe("isUsageExceeded", () => {
5359
});
5460
});
5561

62+
describe("isCodeUsageFreeTier", () => {
63+
it.each([
64+
[false, true],
65+
[true, false],
66+
// Absent means unknown, never free.
67+
[undefined, false],
68+
] as const)("code_usage_subscribed=%s -> %s", (subscribed, expected) => {
69+
expect(isCodeUsageFreeTier({ code_usage_subscribed: subscribed })).toBe(
70+
expected,
71+
);
72+
});
73+
74+
it("treats missing usage as not confirmed free", () => {
75+
expect(isCodeUsageFreeTier(null)).toBe(false);
76+
expect(isCodeUsageFreeTier(undefined)).toBe(false);
77+
});
78+
});
79+
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+
56148
describe("formatResetTime", () => {
57149
const NOW = Date.parse("2026-05-01T12:00:00.000Z");
58150
const isoAt = (msFromNow: number) => new Date(NOW + msFromNow).toISOString();
@@ -73,6 +165,11 @@ describe("formatResetTime", () => {
73165
resetAt: isoAt(4 * 3600 * 1000),
74166
expected: "Resets in 4h",
75167
},
168+
{
169+
name: "rolls the hour instead of showing 60 minutes",
170+
resetAt: isoAt((23 * 3600 + 59 * 60 + 40) * 1000),
171+
expected: "Resets in 24h",
172+
},
76173
{
77174
name: "returns localized date when over 24h away",
78175
resetAt: isoAt(30 * 86400 * 1000),

packages/core/src/billing/usageDisplay.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,55 @@
1-
import type { UsageOutput } from "../usage/schemas";
1+
import type { UsageBucket, 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+
}
9+
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+
}
553

654
export function isUsageExceeded(usage: UsageOutput): boolean {
755
return (
@@ -22,8 +70,12 @@ export function formatResetTime(
2270

2371
const totalHours = ms / 3_600_000;
2472
if (totalHours < 24) {
25-
const hours = Math.floor(totalHours);
26-
const minutes = Math.round((totalHours - hours) * 60);
73+
let hours = Math.floor(totalHours);
74+
let minutes = Math.round((totalHours - hours) * 60);
75+
if (minutes === 60) {
76+
hours += 1;
77+
minutes = 0;
78+
}
2779
return minutes === 0
2880
? `Resets in ${hours}h`
2981
: `Resets in ${hours}h ${minutes}m`;

packages/core/src/llm-gateway/llm-gateway.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ describe("LlmGatewayService.fetchUsage", () => {
368368
const fetchMock = vi.fn().mockResolvedValue(
369369
createJsonResponse({
370370
...USAGE_BODY,
371-
ai_credits: { exhausted: true },
371+
ai_credits: { exhausted: true, used_usd: 12.4, limit_usd: 50 },
372372
code_usage_subscribed: true,
373373
}),
374374
);
@@ -377,9 +377,27 @@ describe("LlmGatewayService.fetchUsage", () => {
377377
const usage = await service.fetchUsage();
378378

379379
expect(usage.ai_credits?.exhausted).toBe(true);
380+
expect(usage.ai_credits?.used_usd).toBe(12.4);
381+
expect(usage.ai_credits?.limit_usd).toBe(50);
380382
expect(usage.code_usage_subscribed).toBe(true);
381383
});
382384

385+
it("parses ai_credits with null spend numbers from an unsynced org", async () => {
386+
const fetchMock = vi.fn().mockResolvedValue(
387+
createJsonResponse({
388+
...USAGE_BODY,
389+
ai_credits: { exhausted: false, used_usd: null, limit_usd: null },
390+
}),
391+
);
392+
const { service } = createService(fetchMock);
393+
394+
const usage = await service.fetchUsage();
395+
396+
expect(usage.ai_credits?.exhausted).toBe(false);
397+
expect(usage.ai_credits?.used_usd).toBeNull();
398+
expect(usage.ai_credits?.limit_usd).toBeNull();
399+
});
400+
383401
it("feeds code_usage_subscribed into helper model routing", async () => {
384402
const fetchMock = vi
385403
.fn()

packages/core/src/usage/schemas.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ export const usageOutput = z.object({
1111
user_id: z.number(),
1212
sustained: usageBucketSchema,
1313
burst: usageBucketSchema,
14-
ai_credits: z.object({ exhausted: z.boolean() }).optional(),
14+
ai_credits: z
15+
.object({
16+
exhausted: z.boolean(),
17+
used_usd: z.number().nullish(),
18+
limit_usd: z.number().nullish(),
19+
})
20+
.optional(),
1521
is_rate_limited: z.boolean(),
1622
is_pro: z.boolean(),
1723
code_usage_subscribed: z.boolean().optional(),

0 commit comments

Comments
 (0)