Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,26 @@ snapshots:
hash: v1.k4693efd2.b76d39ed8142d4449cb5befcde538bb11a6bffcbed66e81bdc3aa8e2746b7e06.GSIfBsgE-3RYuqlLUpluZ87C6-KjBUaTj2eu_a5elZ8
autoresearch-runtime-stats--with-context-usage--light:
hash: v1.k4693efd2.18889b5eda942cd19b0f7be00a87925d632529ca4111fac180d6339f7d34d1ca.X3s9IXrx2-TdQaJEjf1td3hwvNzeIp3Xq3uQh9Gwt5A
billing-usagemeter--exceeded--dark:
hash: v1.k4693efd2.dc026c5cace4e44c4cc713b95654e278d7bf0439a4862e9ceea39a033974815d.m5Be4dAOwwmt-UlEnhbhrKiym1rlGUiGZ4oyUonUoVg
billing-usagemeter--exceeded--light:
hash: v1.k4693efd2.850bf1e451a18981d9e3be653837dd282c6be289ac9c0826ff6a981ac67e1e38.1GwpfJ_4dUlMO7TXp8AOM5YmJuItGGB50ihKkkeIFgg
billing-usagemeter--free-tier--dark:
hash: v1.k4693efd2.164d54cac22cfa05dbbf25fae1a62e77c496b1981dcaff6b8e1ab07fc802f5e0.Gh75uI1vHbGWAgKun41BYoX6LZBYX_FVI7Qu-rFlDV4
billing-usagemeter--free-tier--light:
hash: v1.k4693efd2.5e220fe66fac4c54b4cb9f5ac6ec1054e0b709374fee2c58fdba782f5b2888df.W5la88YA6uOSc1tg30raKaRy12plPGxXl5o7uowL2gc
billing-usagemeter--subscribed-past-included--dark:
hash: v1.k4693efd2.ac987b7184aaa4f8895d9b75c6b7e1177efcb9a21cca0668caf533ee0d5eaab3.2Mq2GLXV83ebM8LKhTQY8csv6BeskWv5yPu8NSAQ20Y
billing-usagemeter--subscribed-past-included--light:
hash: v1.k4693efd2.0c4a37beaceb37baab57e6da98321b236c20127731fd5eaeb61677de4580c204.fZ2eDGlkEijZjHQyatiHtP39EFYT6EO1Vn_f8Tz7b0E
billing-usagemeter--subscribed-with-breakdown--dark:
hash: v1.k4693efd2.f74f8f76e51ee14e427423e1757859e3f001b13e6a43d1e7310b5b0cbbb570b5.eMwK2csA86oBarnAya99W87MfP6Z6PMfdjwbCmnPrw4
billing-usagemeter--subscribed-with-breakdown--light:
hash: v1.k4693efd2.1335dba8bf16980c987882641619e114584343ec50bb69b3c01e63507a13756b._v7_6tsVMmW3WixSeFN-puHQVeIxf9AAGxFZ8Ni97YQ
billing-usagemeter--zero-spend-limit--dark:
hash: v1.k4693efd2.1d199b2c4bba8034cb18fb5b49866b64a6eb3311591add5f58ec288710dce2e9.FCc9egSaO1Onih417yciSYKNROe8zqDZyyS6xuXCZGw
billing-usagemeter--zero-spend-limit--light:
hash: v1.k4693efd2.b50c18736bbde45fc89f4c0ac7fd616286e5c1ecc2a56910fffca13eaa13d8a2.31z4F0JmvMeR4yqk5pjs_OQ3MypnM2miZI9al4wuc8c
components-permissions-permissionselector--create-new-file--dark:
hash: v1.k4693efd2.c54203a4e636b83b3d24d7ed9c4ace8659db87cd8f231d9dc2ecc03320e31646.epDm7LebiLzlp0uuZBrE-Obt_anAn0xsE8bHFnm5vos
components-permissions-permissionselector--create-new-file--light:
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/billing/usageDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import type { UsageOutput } from "../usage/schemas";
import {
codeOrgSpendLimitUsd,
codeUsageMeter,
formatResetTime,
formatUsageBreakdown,
formatUsdAmount,
isCodeUsageFreeTier,
isUsageExceeded,
Expand Down Expand Up @@ -92,9 +94,32 @@ describe("codeUsageMeter", () => {
percent: 25,
exceeded: false,
resetAt: "2026-06-01T00:00:00.000Z",
breakdown: { includedUsd: 20, spendLimitUsd: 30 },
});
});

it("splits a default-settings subscribed limit into $20 included + $50 spend limit", () => {
const meter = codeUsageMeter({
...makeUsage(),
code_usage_subscribed: true,
ai_credits: { exhausted: false, used_usd: 5, limit_usd: 70 },
});
expect(meter).toMatchObject({
kind: "dollars",
limitUsd: 70,
breakdown: { includedUsd: 20, spendLimitUsd: 50 },
});
});

it("keeps a free org's dollars meter breakdown-free — its limit is just the allowance", () => {
const meter = codeUsageMeter({
...makeUsage(),
code_usage_subscribed: false,
ai_credits: { exhausted: false, used_usd: 5, limit_usd: 20 },
});
expect(meter).toMatchObject({ kind: "dollars", breakdown: null });
});

it("marks the dollars meter exceeded from the org bucket and falls back to the sustained reset", () => {
const meter = codeUsageMeter({
...makeUsage(),
Expand Down Expand Up @@ -134,6 +159,59 @@ describe("codeUsageMeter", () => {
});
});

describe("codeOrgSpendLimitUsd", () => {
const subscribedWithLimit = (limitUsd: number | null) => ({
code_usage_subscribed: true,
ai_credits: { exhausted: false, used_usd: 0, limit_usd: limitUsd },
});

it.each([
["default settings", 70, 50],
["custom limit", 120.5, 100.5],
["a $0 spend limit is a real answer", 20, 0],
])("recovers the configured limit with %s", (_name, limitUsd, expected) => {
expect(codeOrgSpendLimitUsd(subscribedWithLimit(limitUsd))).toBe(expected);
});

it("returns null when the merged limit is below the allowance", () => {
expect(codeOrgSpendLimitUsd(subscribedWithLimit(15))).toBeNull();
});

it("returns null without a limit number", () => {
expect(codeOrgSpendLimitUsd(subscribedWithLimit(null))).toBeNull();
expect(
codeOrgSpendLimitUsd({
code_usage_subscribed: true,
ai_credits: undefined,
}),
).toBeNull();
});

it("returns null for free, unknown, or missing orgs", () => {
expect(
codeOrgSpendLimitUsd({
code_usage_subscribed: false,
ai_credits: { exhausted: false, used_usd: 0, limit_usd: 20 },
}),
).toBeNull();
expect(
codeOrgSpendLimitUsd({
code_usage_subscribed: undefined,
ai_credits: { exhausted: false, used_usd: 0, limit_usd: 70 },
}),
).toBeNull();
expect(codeOrgSpendLimitUsd(null)).toBeNull();
});
});

describe("formatUsageBreakdown", () => {
it("phrases the merged limit as included + spend limit", () => {
expect(formatUsageBreakdown({ includedUsd: 20, spendLimitUsd: 50 })).toBe(
"$20 included + $50 org spend limit",
);
});
});

describe("formatUsdAmount", () => {
it.each([
[50, "$50"],
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/billing/usageDisplay.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
import type { UsageBucket, UsageOutput } from "../usage/schemas";

export const CODE_INCLUDED_USAGE_USD = 20;
Comment thread
adboio marked this conversation as resolved.

/** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */
export function isCodeUsageFreeTier(
usage: Pick<UsageOutput, "code_usage_subscribed"> | null | undefined,
): boolean {
return usage?.code_usage_subscribed === false;
}

export function codeOrgSpendLimitUsd(
usage:
| Pick<UsageOutput, "code_usage_subscribed" | "ai_credits">
| null
| undefined,
): number | null {
if (usage?.code_usage_subscribed !== true) return null;
const limitUsd = usage.ai_credits?.limit_usd;
if (limitUsd == null || limitUsd < CODE_INCLUDED_USAGE_USD) return null;
return Math.round((limitUsd - CODE_INCLUDED_USAGE_USD) * 100) / 100;
}

export interface CodeUsageBreakdown {
includedUsd: number;
spendLimitUsd: number;
}

export type CodeUsageMeter =
| {
kind: "dollars";
Expand All @@ -15,6 +34,7 @@ export type CodeUsageMeter =
percent: number;
exceeded: boolean;
resetAt: string;
breakdown: CodeUsageBreakdown | null;
}
| { kind: "bucket"; bucket: UsageBucket }
| { kind: "hidden" };
Expand All @@ -32,13 +52,18 @@ export function codeUsageMeter(
const usedUsd = usage.ai_credits?.used_usd;
const limitUsd = usage.ai_credits?.limit_usd;
if (usedUsd != null && limitUsd != null && limitUsd > 0) {
const spendLimitUsd = codeOrgSpendLimitUsd(usage);
return {
kind: "dollars",
usedUsd,
limitUsd,
percent: Math.min(100, Math.round((usedUsd / limitUsd) * 100)),
exceeded: usage.ai_credits?.exhausted === true,
resetAt: usage.billing_period_end ?? usage.sustained.reset_at,
breakdown:
spendLimitUsd != null
? { includedUsd: CODE_INCLUDED_USAGE_USD, spendLimitUsd }
: null,
};
}
if (isCodeUsageFreeTier(usage)) {
Expand All @@ -51,6 +76,10 @@ export function formatUsdAmount(amount: number): string {
return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`;
}

export function formatUsageBreakdown(breakdown: CodeUsageBreakdown): string {
return `${formatUsdAmount(breakdown.includedUsd)} included + ${formatUsdAmount(breakdown.spendLimitUsd)} org spend limit`;
}

export function isUsageExceeded(usage: UsageOutput): boolean {
return (
usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ArrowSquareOut, CreditCard } from "@phosphor-icons/react";
import { formatUsdAmount } from "@posthog/core/billing/usageDisplay";
import {
codeOrgSpendLimitUsd,
formatUsdAmount,
} from "@posthog/core/billing/usageDisplay";
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { Button, Dialog, Flex, Text } from "@radix-ui/themes";
import { useEffect } from "react";
Expand All @@ -22,12 +25,7 @@ export function UsageBillingAnnouncementModal() {
const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
const { usage } = useUsage({ enabled: isOpen });

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

useEffect(() => {
if (isOpen) {
Expand Down Expand Up @@ -83,10 +81,10 @@ export function UsageBillingAnnouncementModal() {
• Premium models need a payment method; an open model stays free.
</Text>
<Text color="gray">
{limitUsd != null ? (
{spendLimitUsd != null ? (
<>
• Your organization's spend limit is{" "}
<Text weight="medium">{`${formatUsdAmount(limitUsd)}/month`}</Text>{" "}
<Text weight="medium">{`${formatUsdAmount(spendLimitUsd)}/month`}</Text>{" "}
— adjust it any time in billing settings.
</>
) : (
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/features/billing/UsageButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Circle } from "@phosphor-icons/react";
import {
formatResetTime,
formatUsageBreakdown,
formatUsdAmount,
} from "@posthog/core/billing/usageDisplay";
import {
Expand Down Expand Up @@ -70,6 +71,10 @@ export function UsageButton() {
const resetLabel = formatResetTime(
meter.kind === "dollars" ? meter.resetAt : meter.bucket.reset_at,
);
const breakdownLabel =
meter.kind === "dollars" && meter.breakdown
? formatUsageBreakdown(meter.breakdown)
: null;

// Upgrade-prompt analytics only apply to free-tier orgs — a subscribed
// org's meter is not an upgrade prompt.
Expand Down Expand Up @@ -158,7 +163,7 @@ export function UsageButton() {
variant={blocked ? "destructive" : "default"}
/>
<div className="font-normal text-[11px] text-muted-foreground">
{resetLabel}
{breakdownLabel ? `${breakdownLabel} · ${resetLabel}` : resetLabel}
</div>
</PopoverContent>
</Popover>
Expand Down
77 changes: 77 additions & 0 deletions packages/ui/src/features/billing/UsageMeter.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { UsageMeter } from "@posthog/ui/features/billing/UsageMeter";
import type { Meta, StoryObj } from "@storybook/react-vite";

const meta: Meta<typeof UsageMeter> = {
title: "Billing/UsageMeter",
component: UsageMeter,
decorators: [
(Story) => (
<div style={{ maxWidth: 560 }}>
<Story />
</div>
),
],
};

export default meta;
type Story = StoryObj<typeof UsageMeter>;

// A subscribed org on default settings: the $70 limit renders as two
// segments — the $20 included allowance (green) and the $50 default spend
// limit (accent) — with a dot legend naming each amount. Usage still inside
// the included allowance only fills the green segment.
export const SubscribedWithBreakdown: Story = {
args: {
label: "Usage this period",
percent: 18,
valueLabel: "$12.40 of $70",
detail: "Resets Jul 31 at 2:00 PM PDT",
breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 12.4 },
},
};

// Past the allowance: the green segment is full and billable usage fills the
// accent segment.
export const SubscribedPastIncluded: Story = {
args: {
label: "Usage this period",
percent: 66,
valueLabel: "$46.20 of $70",
detail: "Resets Jul 31 at 2:00 PM PDT",
breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 46.2 },
},
};

// A subscribed org that set its spend limit to $0: only the included segment
// (and its legend entry) renders.
export const ZeroSpendLimit: Story = {
args: {
label: "Usage this period",
percent: 65,
valueLabel: "$13 of $20",
detail: "Resets Jul 31 at 2:00 PM PDT",
breakdown: { includedUsd: 20, spendLimitUsd: 0, usedUsd: 13 },
},
};

// Free tier has no breakdown — its limit IS the allowance, so the plain
// single-track bar renders.
export const FreeTier: Story = {
args: {
label: "Monthly free usage",
percent: 62,
valueLabel: "$12.40 of $20 included",
detail: "Resets Jul 31 at 2:00 PM PDT",
},
};

export const Exceeded: Story = {
args: {
label: "Usage this period",
percent: 100,
valueLabel: "$70 of $70",
detail: "Limit exceeded. Resets Jul 31 at 2:00 PM PDT",
breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 70 },
color: "red",
},
};
Loading
Loading