diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index f006547cc7..cfbdd290b1 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -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: diff --git a/packages/core/src/billing/usageDisplay.test.ts b/packages/core/src/billing/usageDisplay.test.ts index d7c3f882fd..7ae0c2c050 100644 --- a/packages/core/src/billing/usageDisplay.test.ts +++ b/packages/core/src/billing/usageDisplay.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import type { UsageOutput } from "../usage/schemas"; import { + codeOrgSpendLimitUsd, codeUsageMeter, formatResetTime, + formatUsageBreakdown, formatUsdAmount, isCodeUsageFreeTier, isUsageExceeded, @@ -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(), @@ -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"], diff --git a/packages/core/src/billing/usageDisplay.ts b/packages/core/src/billing/usageDisplay.ts index fcf22434c7..1207cf9d91 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -1,5 +1,7 @@ import type { UsageBucket, UsageOutput } from "../usage/schemas"; +export const CODE_INCLUDED_USAGE_USD = 20; + /** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */ export function isCodeUsageFreeTier( usage: Pick | null | undefined, @@ -7,6 +9,23 @@ export function isCodeUsageFreeTier( return usage?.code_usage_subscribed === false; } +export function codeOrgSpendLimitUsd( + usage: + | Pick + | 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"; @@ -15,6 +34,7 @@ export type CodeUsageMeter = percent: number; exceeded: boolean; resetAt: string; + breakdown: CodeUsageBreakdown | null; } | { kind: "bucket"; bucket: UsageBucket } | { kind: "hidden" }; @@ -32,6 +52,7 @@ 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, @@ -39,6 +60,10 @@ export function codeUsageMeter( 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)) { @@ -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 diff --git a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx index b2484be203..984ebee993 100644 --- a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -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"; @@ -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) { @@ -83,10 +81,10 @@ export function UsageBillingAnnouncementModal() { • Premium models need a payment method; an open model stays free. - {limitUsd != null ? ( + {spendLimitUsd != null ? ( <> • Your organization's spend limit is{" "} - {`${formatUsdAmount(limitUsd)}/month`}{" "} + {`${formatUsdAmount(spendLimitUsd)}/month`}{" "} — adjust it any time in billing settings. ) : ( diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 8f470b5b8b..f6fce7457e 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -1,6 +1,7 @@ import { Circle } from "@phosphor-icons/react"; import { formatResetTime, + formatUsageBreakdown, formatUsdAmount, } from "@posthog/core/billing/usageDisplay"; import { @@ -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. @@ -158,7 +163,7 @@ export function UsageButton() { variant={blocked ? "destructive" : "default"} />
- {resetLabel} + {breakdownLabel ? `${breakdownLabel} · ${resetLabel}` : resetLabel}
diff --git a/packages/ui/src/features/billing/UsageMeter.stories.tsx b/packages/ui/src/features/billing/UsageMeter.stories.tsx new file mode 100644 index 0000000000..290d0fb41d --- /dev/null +++ b/packages/ui/src/features/billing/UsageMeter.stories.tsx @@ -0,0 +1,77 @@ +import { UsageMeter } from "@posthog/ui/features/billing/UsageMeter"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta: Meta = { + title: "Billing/UsageMeter", + component: UsageMeter, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// 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", + }, +}; diff --git a/packages/ui/src/features/billing/UsageMeter.tsx b/packages/ui/src/features/billing/UsageMeter.tsx index 9ea1c91a11..5b708bad12 100644 --- a/packages/ui/src/features/billing/UsageMeter.tsx +++ b/packages/ui/src/features/billing/UsageMeter.tsx @@ -1,19 +1,32 @@ +import { formatUsdAmount } from "@posthog/core/billing/usageDisplay"; +import { cn } from "@posthog/quill"; import { Flex, Progress, Text } from "@radix-ui/themes"; +interface UsageMeterBreakdown { + includedUsd: number; + spendLimitUsd: number; + usedUsd: number; +} + interface UsageMeterProps { label: string; percent: number; valueLabel: string; detail: string; color?: "red"; + breakdown?: UsageMeterBreakdown; } +const clampPercent = (value: number): number => + Math.min(100, Math.max(0, value)); + export function UsageMeter({ label, percent, valueLabel, detail, color, + breakdown, }: UsageMeterProps) { const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; @@ -31,12 +44,94 @@ export function UsageMeter({ {label} {valueLabel} - + {breakdown ? ( + + ) : ( + + )} {detail} ); } + +function SegmentedUsageBar({ + percent, + breakdown, + exceeded, +}: { + percent: number; + breakdown: UsageMeterBreakdown; + exceeded: boolean; +}) { + const { includedUsd, spendLimitUsd, usedUsd } = breakdown; + const totalUsd = includedUsd + spendLimitUsd; + const hasPaidSegment = spendLimitUsd > 0 && totalUsd > 0; + const includedFill = + includedUsd > 0 ? clampPercent((usedUsd / includedUsd) * 100) : 100; + const paidFill = hasPaidSegment + ? clampPercent(((usedUsd - includedUsd) / spendLimitUsd) * 100) + : 0; + const includedDot = exceeded ? "bg-(--red-9)" : "bg-(--green-9)"; + const paidDot = exceeded ? "bg-(--red-9)" : "bg-(--accent-9)"; + + return ( + +
+
+
+
+ {hasPaidSegment && ( +
+
+
+ )} +
+ + + + + {formatUsdAmount(includedUsd)} included + + + {hasPaidSegment && ( + + + + {formatUsdAmount(spendLimitUsd)} org spend limit + + + )} + + + ); +} diff --git a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index 09d0db0884..e794e5afd1 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -179,6 +179,11 @@ export function PlanUsageSettings() { percent={meter.percent} valueLabel={`${formatUsdAmount(meter.usedUsd)} of ${formatUsdAmount(meter.limitUsd)}${freeTier ? " included" : ""}`} detail={`${meter.exceeded ? "Limit exceeded. " : ""}${formatResetTime(meter.resetAt)}`} + breakdown={ + meter.breakdown + ? { ...meter.breakdown, usedUsd: meter.usedUsd } + : undefined + } color={meter.exceeded ? "red" : undefined} /> ) : meter.kind === "bucket" ? (