Skip to content

Commit 45ca3dc

Browse files
committed
feat(billing): org spend limit in the announcement; hold What's New behind it
The announcement's spend-limit bullet shows the org's actual limit from ai_credits.limit_usd (posthog#71404) for subscribed orgs — a free org's limit is its included allocation, already the first bullet — falling back to the $50-default copy when unknown. The post-update What's New dialog now waits until the blocking announcement is acknowledged instead of stacking on top of it. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
1 parent a32d58a commit 45ca3dc

5 files changed

Lines changed: 68 additions & 12 deletions

File tree

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(),

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

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,32 @@
11
import { ArrowSquareOut, CreditCard } from "@phosphor-icons/react";
2-
import { USAGE_BILLING_FLAG } from "@posthog/shared";
32
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
43
import { Button, Dialog, Flex, Text } from "@radix-ui/themes";
54
import { useEffect } from "react";
65
import { track } from "../../shell/analytics";
76
import { openExternalUrl } from "../../shell/openExternal";
87
import { getBillingUrl } from "../../utils/urls";
98
import { useAuthStateValue } from "../auth/store";
10-
import { useFeatureFlag } from "../feature-flags/useFeatureFlag";
119
import { useBillingAnnouncementStore } from "./billingAnnouncementStore";
10+
import { useBillingAnnouncementVisible } from "./useBillingAnnouncementVisible";
11+
import { useUsage } from "./useUsage";
1212

1313
/**
1414
* One-time blocking announcement of the usage-based billing cutover. The
1515
* flag is its launch switch — flip at cutover, delete once the fleet has
1616
* acknowledged.
1717
*/
1818
export function UsageBillingAnnouncementModal() {
19-
const armed = useFeatureFlag(USAGE_BILLING_FLAG);
20-
const acknowledged = useBillingAnnouncementStore((s) => s.acknowledged);
21-
const hasHydrated = useBillingAnnouncementStore((s) => s._hasHydrated);
19+
const isOpen = useBillingAnnouncementVisible();
2220
const acknowledge = useBillingAnnouncementStore((s) => s.acknowledge);
2321
const cloudRegion = useAuthStateValue((state) => state.cloudRegion);
24-
const isLoggedIn = useAuthStateValue((state) => state.currentOrgId !== null);
22+
const { usage } = useUsage({ enabled: isOpen });
2523

26-
const isOpen = armed && isLoggedIn && hasHydrated && !acknowledged;
24+
// A free org's limit_usd is its included allocation (the first bullet's
25+
// $20), not a spend limit — the org-specific line is for subscribed orgs.
26+
const limitUsd =
27+
usage?.code_usage_subscribed === true
28+
? (usage.ai_credits?.limit_usd ?? null)
29+
: null;
2730

2831
useEffect(() => {
2932
if (isOpen) {
@@ -79,8 +82,20 @@ export function UsageBillingAnnouncementModal() {
7982
• Premium models need a payment method; an open model stays free.
8083
</Text>
8184
<Text color="gray">
82-
• A default <Text weight="medium">$50/month</Text> spend limit
83-
applies — adjust it any time in billing settings.
85+
{limitUsd != null ? (
86+
<>
87+
• Your organization's spend limit is{" "}
88+
<Text weight="medium">
89+
{`$${Number.isInteger(limitUsd) ? limitUsd : limitUsd.toFixed(2)}/month`}
90+
</Text>{" "}
91+
— adjust it any time in billing settings.
92+
</>
93+
) : (
94+
<>
95+
• A default <Text weight="medium">$50/month</Text> spend
96+
limit applies — adjust it any time in billing settings.
97+
</>
98+
)}
8499
</Text>
85100
</Flex>
86101
<Flex justify="end" gap="3" mt="2">
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { USAGE_BILLING_FLAG } from "@posthog/shared";
2+
import { useAuthStateValue } from "../auth/store";
3+
import { useFeatureFlag } from "../feature-flags/useFeatureFlag";
4+
import { useBillingAnnouncementStore } from "./billingAnnouncementStore";
5+
6+
/** Whether the one-time billing announcement is currently blocking the app. */
7+
export function useBillingAnnouncementVisible(): boolean {
8+
const armed = useFeatureFlag(USAGE_BILLING_FLAG);
9+
const acknowledged = useBillingAnnouncementStore((s) => s.acknowledged);
10+
const hasHydrated = useBillingAnnouncementStore((s) => s._hasHydrated);
11+
const isLoggedIn = useAuthStateValue((state) => state.currentOrgId !== null);
12+
return armed && isLoggedIn && hasHydrated && !acknowledged;
13+
}

packages/ui/src/features/updates/WhatsNewModal.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { X } from "@phosphor-icons/react";
22
import { useHostTRPC } from "@posthog/host-router/react";
3+
import { useBillingAnnouncementVisible } from "@posthog/ui/features/billing/useBillingAnnouncementVisible";
34
import { ReleaseNotesSections } from "@posthog/ui/features/updates/ReleaseNotesSections";
45
import {
56
groupReleases,
@@ -42,6 +43,9 @@ function ChangelogSkeleton() {
4243
export function WhatsNewModal() {
4344
const isOpen = useWhatsNewStore((state) => state.isOpen);
4445
const close = useWhatsNewStore((state) => state.close);
46+
// The blocking billing announcement takes the stage alone — the post-update
47+
// auto-open waits here until it's acknowledged, then appears.
48+
const billingAnnouncementVisible = useBillingAnnouncementVisible();
4549
const prefetchForActiveUpdate = useHasActiveUpdate();
4650
const hostTRPC = useHostTRPC();
4751
const { data: currentVersion, isError: isVersionError } = useQuery(
@@ -63,7 +67,7 @@ export function WhatsNewModal() {
6367

6468
return (
6569
<Dialog.Root
66-
open={isOpen}
70+
open={isOpen && !billingAnnouncementVisible}
6771
onOpenChange={(open) => {
6872
if (!open) close();
6973
}}

0 commit comments

Comments
 (0)