From 927acb0c7461bc00907c5ff153209a2cce89b5ab Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 16 Jul 2026 09:34:25 -0400 Subject: [PATCH 1/3] 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 --- .../core/src/billing/usageDisplay.test.ts | 78 +++++++++++++++++++ packages/core/src/billing/usageDisplay.ts | 51 ++++++++++++ .../billing/UsageBillingAnnouncementModal.tsx | 20 ++--- .../ui/src/features/billing/UsageButton.tsx | 10 ++- .../features/billing/UsageMeter.stories.tsx | 61 +++++++++++++++ .../ui/src/features/billing/UsageMeter.tsx | 25 ++++-- .../settings/sections/PlanUsageSettings.tsx | 18 ++++- 7 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/features/billing/UsageMeter.stories.tsx 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..3d32bf3f62 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -1,5 +1,16 @@ import type { UsageBucket, UsageOutput } from "../usage/schemas"; +/** + * The monthly usage allowance included for every org under Code usage + * billing — the "first $20 each month is included" from the billing product + * config. The gateway has no field for it: a subscribed org's + * `ai_credits.limit_usd` arrives as one merged number (allowance + configured + * spend limit), so display code peels the allowance back off with this + * constant. If billing ever changes the allowance or starts sending a + * breakdown, this constant (and the copy quoting $20) is the seam. + */ +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 +18,32 @@ export function isCodeUsageFreeTier( return usage?.code_usage_subscribed === false; } +/** + * The spend limit the org actually configured in billing settings ($50 by + * default), recovered from the merged `limit_usd`. Null when it can't be + * recovered: unconfirmed/free orgs (their limit IS the allowance), missing + * numbers, or a merged limit below the allowance. Zero is a real answer — a + * subscribed org whose merged limit equals the allowance set its spend limit + * to $0. + */ +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; + // Round away float dust — the wire amounts are already cent-rounded. + return Math.round((limitUsd - CODE_INCLUDED_USAGE_USD) * 100) / 100; +} + +export interface CodeUsageBreakdown { + includedUsd: number; + spendLimitUsd: number; +} + export type CodeUsageMeter = | { kind: "dollars"; @@ -15,6 +52,10 @@ export type CodeUsageMeter = percent: number; exceeded: boolean; resetAt: string; + // How the merged limit decomposes for a subscribed org, so the meter + // can explain "$70" as $20 included + $50 spend limit. Null when the + // split is unknown (free tier, or a limit below the allowance). + breakdown: CodeUsageBreakdown | null; } | { kind: "bucket"; bucket: UsageBucket } | { kind: "hidden" }; @@ -32,6 +73,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 +81,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 +97,11 @@ export function formatUsdAmount(amount: number): string { return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`; } +/** One shared phrasing for the merged-limit explanation, e.g. "$20 included + $50 org spend limit". */ +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..5982865af2 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,11 @@ 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; + // A subscribed org's limit_usd merges the included $20 with the configured + // spend limit — quote the recovered spend limit, the number the org set. + // Free orgs (whose limit_usd is just the first bullet's $20) get the + // default-limit line instead. + const spendLimitUsd = codeOrgSpendLimitUsd(usage); useEffect(() => { if (isOpen) { @@ -83,10 +85,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..893a461d75 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,13 @@ export function UsageButton() { const resetLabel = formatResetTime( meter.kind === "dollars" ? meter.resetAt : meter.bucket.reset_at, ); + // The subscribed meter's limit is a merged number (included allowance + + // configured spend limit); without the split, "$70" matches nothing the + // user ever set. + 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 +166,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..0d37009bea --- /dev/null +++ b/packages/ui/src/features/billing/UsageMeter.stories.tsx @@ -0,0 +1,61 @@ +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 is $20 included +// allowance + the $50 default spend limit, spelled out in the detail line +// with the notch marking where the included allowance ends. +export const SubscribedWithBreakdown: Story = { + args: { + label: "Usage this period", + percent: 18, + valueLabel: "$12.40 of $70", + detail: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", + markerPercent: (20 / 70) * 100, + }, +}; + +export const SubscribedPastIncluded: Story = { + args: { + label: "Usage this period", + percent: 66, + valueLabel: "$46.20 of $70", + detail: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", + markerPercent: (20 / 70) * 100, + }, +}; + +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. $20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", + markerPercent: (20 / 70) * 100, + color: "red", + }, +}; diff --git a/packages/ui/src/features/billing/UsageMeter.tsx b/packages/ui/src/features/billing/UsageMeter.tsx index 9ea1c91a11..43ce470d23 100644 --- a/packages/ui/src/features/billing/UsageMeter.tsx +++ b/packages/ui/src/features/billing/UsageMeter.tsx @@ -6,6 +6,9 @@ interface UsageMeterProps { valueLabel: string; detail: string; color?: "red"; + // Where a boundary inside the limit falls (e.g. the end of the included + // allowance), as a percent of the bar. Rendered as a notch over the track. + markerPercent?: number; } export function UsageMeter({ @@ -14,8 +17,11 @@ export function UsageMeter({ valueLabel, detail, color, + markerPercent, }: UsageMeterProps) { const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; + const showMarker = + markerPercent != null && markerPercent > 0 && markerPercent < 100; return ( {label}
{valueLabel} - +
+ + {showMarker && ( +
+ )} +
{detail} ); diff --git a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index 09d0db0884..014d7595cf 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -6,6 +6,7 @@ import { import { codeUsageMeter, formatResetTime, + formatUsageBreakdown, formatUsdAmount, isCodeUsageFreeTier, } from "@posthog/core/billing/usageDisplay"; @@ -178,7 +179,22 @@ export function PlanUsageSettings() { label={freeTier ? "Monthly free usage" : "Usage this period"} percent={meter.percent} valueLabel={`${formatUsdAmount(meter.usedUsd)} of ${formatUsdAmount(meter.limitUsd)}${freeTier ? " included" : ""}`} - detail={`${meter.exceeded ? "Limit exceeded. " : ""}${formatResetTime(meter.resetAt)}`} + detail={[ + meter.exceeded ? "Limit exceeded." : null, + // Spell out how the merged limit is composed — "$70" on its + // own reads as a mystery number to an org that set $50. + meter.breakdown + ? `${formatUsageBreakdown(meter.breakdown)}.` + : null, + formatResetTime(meter.resetAt), + ] + .filter(Boolean) + .join(" ")} + markerPercent={ + meter.breakdown + ? (meter.breakdown.includedUsd / meter.limitUsd) * 100 + : undefined + } color={meter.exceeded ? "red" : undefined} /> ) : meter.kind === "bucket" ? ( From 82b3e9ce48c195c6034f88e48bc17314e11a43f3 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 16 Jul 2026 09:57:10 -0400 Subject: [PATCH 2/3] feat(billing): segment the usage bar into included vs spend-limit portions The 1px notch marking where the included allowance ends was barely visible in either theme. Replace it with a physically segmented bar: the $20 included allowance and the org spend limit render as separate gap-divided segments, usage fills the included segment green before pouring into the accent spend-limit segment, and a dot legend names each amount. Exceeded turns both segments and dots red; a $0 spend limit collapses to the included segment alone; the free tier keeps the plain single-track bar. Generated-By: PostHog Code Task-Id: b24f71e2-3751-4a6b-b5d8-a24147c4c067 --- packages/core/src/billing/usageDisplay.ts | 22 ---- .../billing/UsageBillingAnnouncementModal.tsx | 4 - .../ui/src/features/billing/UsageButton.tsx | 3 - .../features/billing/UsageMeter.stories.tsx | 36 ++++-- .../ui/src/features/billing/UsageMeter.tsx | 104 ++++++++++++++++-- .../settings/sections/PlanUsageSettings.tsx | 17 +-- 6 files changed, 121 insertions(+), 65 deletions(-) diff --git a/packages/core/src/billing/usageDisplay.ts b/packages/core/src/billing/usageDisplay.ts index 3d32bf3f62..1207cf9d91 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -1,14 +1,5 @@ import type { UsageBucket, UsageOutput } from "../usage/schemas"; -/** - * The monthly usage allowance included for every org under Code usage - * billing — the "first $20 each month is included" from the billing product - * config. The gateway has no field for it: a subscribed org's - * `ai_credits.limit_usd` arrives as one merged number (allowance + configured - * spend limit), so display code peels the allowance back off with this - * constant. If billing ever changes the allowance or starts sending a - * breakdown, this constant (and the copy quoting $20) is the seam. - */ export const CODE_INCLUDED_USAGE_USD = 20; /** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */ @@ -18,14 +9,6 @@ export function isCodeUsageFreeTier( return usage?.code_usage_subscribed === false; } -/** - * The spend limit the org actually configured in billing settings ($50 by - * default), recovered from the merged `limit_usd`. Null when it can't be - * recovered: unconfirmed/free orgs (their limit IS the allowance), missing - * numbers, or a merged limit below the allowance. Zero is a real answer — a - * subscribed org whose merged limit equals the allowance set its spend limit - * to $0. - */ export function codeOrgSpendLimitUsd( usage: | Pick @@ -35,7 +18,6 @@ export function codeOrgSpendLimitUsd( 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; - // Round away float dust — the wire amounts are already cent-rounded. return Math.round((limitUsd - CODE_INCLUDED_USAGE_USD) * 100) / 100; } @@ -52,9 +34,6 @@ export type CodeUsageMeter = percent: number; exceeded: boolean; resetAt: string; - // How the merged limit decomposes for a subscribed org, so the meter - // can explain "$70" as $20 included + $50 spend limit. Null when the - // split is unknown (free tier, or a limit below the allowance). breakdown: CodeUsageBreakdown | null; } | { kind: "bucket"; bucket: UsageBucket } @@ -97,7 +76,6 @@ export function formatUsdAmount(amount: number): string { return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`; } -/** One shared phrasing for the merged-limit explanation, e.g. "$20 included + $50 org spend limit". */ export function formatUsageBreakdown(breakdown: CodeUsageBreakdown): string { return `${formatUsdAmount(breakdown.includedUsd)} included + ${formatUsdAmount(breakdown.spendLimitUsd)} org spend limit`; } diff --git a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx index 5982865af2..984ebee993 100644 --- a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -25,10 +25,6 @@ export function UsageBillingAnnouncementModal() { const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const { usage } = useUsage({ enabled: isOpen }); - // A subscribed org's limit_usd merges the included $20 with the configured - // spend limit — quote the recovered spend limit, the number the org set. - // Free orgs (whose limit_usd is just the first bullet's $20) get the - // default-limit line instead. const spendLimitUsd = codeOrgSpendLimitUsd(usage); useEffect(() => { diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 893a461d75..f6fce7457e 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -71,9 +71,6 @@ export function UsageButton() { const resetLabel = formatResetTime( meter.kind === "dollars" ? meter.resetAt : meter.bucket.reset_at, ); - // The subscribed meter's limit is a merged number (included allowance + - // configured spend limit); without the split, "$70" matches nothing the - // user ever set. const breakdownLabel = meter.kind === "dollars" && meter.breakdown ? formatUsageBreakdown(meter.breakdown) diff --git a/packages/ui/src/features/billing/UsageMeter.stories.tsx b/packages/ui/src/features/billing/UsageMeter.stories.tsx index 0d37009bea..290d0fb41d 100644 --- a/packages/ui/src/features/billing/UsageMeter.stories.tsx +++ b/packages/ui/src/features/billing/UsageMeter.stories.tsx @@ -16,29 +16,46 @@ const meta: Meta = { export default meta; type Story = StoryObj; -// A subscribed org on default settings: the $70 limit is $20 included -// allowance + the $50 default spend limit, spelled out in the detail line -// with the notch marking where the included allowance ends. +// 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: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", - markerPercent: (20 / 70) * 100, + 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: "$20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", - markerPercent: (20 / 70) * 100, + 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", @@ -53,9 +70,8 @@ export const Exceeded: Story = { label: "Usage this period", percent: 100, valueLabel: "$70 of $70", - detail: - "Limit exceeded. $20 included + $50 org spend limit. Resets Jul 31 at 2:00 PM PDT", - markerPercent: (20 / 70) * 100, + 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 43ce470d23..5b708bad12 100644 --- a/packages/ui/src/features/billing/UsageMeter.tsx +++ b/packages/ui/src/features/billing/UsageMeter.tsx @@ -1,27 +1,34 @@ +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"; - // Where a boundary inside the limit falls (e.g. the end of the included - // allowance), as a percent of the bar. Rendered as a notch over the track. - markerPercent?: number; + breakdown?: UsageMeterBreakdown; } +const clampPercent = (value: number): number => + Math.min(100, Math.max(0, value)); + export function UsageMeter({ label, percent, valueLabel, detail, color, - markerPercent, + breakdown, }: UsageMeterProps) { const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; - const showMarker = - markerPercent != null && markerPercent > 0 && markerPercent < 100; return ( {label} {valueLabel} -
+ {breakdown ? ( + + ) : ( - {showMarker && ( + )} + {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 && ( +
+
+
)}
- {detail} + + + + + {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 014d7595cf..e794e5afd1 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -6,7 +6,6 @@ import { import { codeUsageMeter, formatResetTime, - formatUsageBreakdown, formatUsdAmount, isCodeUsageFreeTier, } from "@posthog/core/billing/usageDisplay"; @@ -179,20 +178,10 @@ export function PlanUsageSettings() { label={freeTier ? "Monthly free usage" : "Usage this period"} percent={meter.percent} valueLabel={`${formatUsdAmount(meter.usedUsd)} of ${formatUsdAmount(meter.limitUsd)}${freeTier ? " included" : ""}`} - detail={[ - meter.exceeded ? "Limit exceeded." : null, - // Spell out how the merged limit is composed — "$70" on its - // own reads as a mystery number to an org that set $50. + detail={`${meter.exceeded ? "Limit exceeded. " : ""}${formatResetTime(meter.resetAt)}`} + breakdown={ meter.breakdown - ? `${formatUsageBreakdown(meter.breakdown)}.` - : null, - formatResetTime(meter.resetAt), - ] - .filter(Boolean) - .join(" ")} - markerPercent={ - meter.breakdown - ? (meter.breakdown.includedUsd / meter.limitUsd) * 100 + ? { ...meter.breakdown, usedUsd: meter.usedUsd } : undefined } color={meter.exceeded ? "red" : undefined} From 2a85d33bf1bcd7edc48293219ea0880a65b3efa8 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:28:39 +0000 Subject: [PATCH 3/3] chore(visual): update storybook baselines 10 updated Run: 31ffb0f9-8ac6-42a7-9d0d-21c01935161b Co-authored-by: adboio <23323033+adboio@users.noreply.github.com> --- apps/code/snapshots.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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: