Skip to content

Commit 2933a83

Browse files
authored
fix(billing): scope the usage snapshot to the identity; emit on dollar changes; fail hydration open
Review fixes: isSameUsage now compares ai_credits.used_usd/limit_usd so a subscribed org's dollar meter doesn't freeze all period (nothing else in its snapshot ever changes); the usage monitor clears its snapshot on org transitions and the renderer drops the cached query on identity change, so a new sign-in never sees the previous account's spend; a failed announcement-store hydration now fails open as unacknowledged — re-showing the one-time modal beats never showing it, and in-memory state still dismisses it for the session. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
1 parent f9074a6 commit 2933a83

5 files changed

Lines changed: 110 additions & 3 deletions

File tree

packages/core/src/usage/usage-monitor.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { AuthService } from "../auth/auth";
23
import type { UsageHost } from "./identifiers";
34
import { UsageMonitorEvent } from "./monitor-schemas";
45
import type { UsageOutput } from "./schemas";
@@ -50,6 +51,26 @@ function makeLogger() {
5051

5152
type GatewaySlice = Pick<UsageHost, "fetchUsage">;
5253

54+
let emitAuthState: (currentOrgId: string | null) => void = () => {};
55+
56+
function makeAuthService(): AuthService {
57+
const listeners = new Set<
58+
(state: { currentOrgId: string | null }) => void
59+
>();
60+
emitAuthState = (currentOrgId) => {
61+
for (const listener of [...listeners]) {
62+
listener({ currentOrgId });
63+
}
64+
};
65+
return {
66+
getState: () => ({ currentOrgId: "org-1" }),
67+
on: (
68+
_event: string,
69+
listener: (state: { currentOrgId: string | null }) => void,
70+
) => listeners.add(listener),
71+
} as unknown as AuthService;
72+
}
73+
5374
function makeService(
5475
gateway: GatewaySlice,
5576
activity: ActivitySlice,
@@ -59,7 +80,7 @@ function makeService(
5980
...activity,
6081
...makeThresholdStore(),
6182
};
62-
return new UsageMonitorService(host, makeLogger());
83+
return new UsageMonitorService(host, makeLogger(), makeAuthService());
6384
}
6485

6586
function makeUsage(overrides?: {
@@ -273,6 +294,65 @@ describe("UsageMonitorService", () => {
273294

274295
// The titlebar meter keys off this bit; a subscribe flip must not wait for
275296
// some other field to change.
297+
it("emits UsageUpdated when only the org spend numbers change", async () => {
298+
const updates: UsageOutput[] = [];
299+
const gateway = {
300+
fetchUsage: vi
301+
.fn()
302+
.mockResolvedValueOnce({
303+
...makeUsage(),
304+
ai_credits: { exhausted: false, used_usd: 12.4, limit_usd: 50 },
305+
})
306+
.mockResolvedValueOnce({
307+
...makeUsage(),
308+
ai_credits: { exhausted: false, used_usd: 13.1, limit_usd: 50 },
309+
}),
310+
} as unknown as GatewaySlice;
311+
service = makeService(gateway, makeActivityMonitor());
312+
service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u));
313+
314+
await service.fetchOnce();
315+
await service.fetchOnce();
316+
317+
expect(updates).toHaveLength(2);
318+
expect(updates[1].ai_credits?.used_usd).toBe(13.1);
319+
});
320+
321+
it("forgets the snapshot and refetches when the organization changes", async () => {
322+
const gateway = mockGateway(makeUsage());
323+
service = makeService(gateway, makeActivityMonitor());
324+
await service.fetchOnce();
325+
expect(service.getLatest()).not.toBeNull();
326+
327+
emitAuthState("org-2");
328+
329+
expect(service.getLatest()).toBeNull();
330+
await vi.advanceTimersByTimeAsync(10_000);
331+
expect(gateway.fetchUsage).toHaveBeenCalledTimes(2);
332+
expect(service.getLatest()).not.toBeNull();
333+
});
334+
335+
it("keeps the snapshot across same-org auth changes", async () => {
336+
service = makeService(mockGateway(makeUsage()), makeActivityMonitor());
337+
await service.fetchOnce();
338+
339+
emitAuthState("org-1");
340+
341+
expect(service.getLatest()).not.toBeNull();
342+
});
343+
344+
it("clears the snapshot on sign-out without scheduling a refetch", async () => {
345+
const gateway = mockGateway(makeUsage());
346+
service = makeService(gateway, makeActivityMonitor());
347+
await service.fetchOnce();
348+
349+
emitAuthState(null);
350+
351+
expect(service.getLatest()).toBeNull();
352+
await vi.advanceTimersByTimeAsync(10_000);
353+
expect(gateway.fetchUsage).toHaveBeenCalledTimes(1);
354+
});
355+
276356
it("emits UsageUpdated when only the subscription bit flips", async () => {
277357
const updates: UsageOutput[] = [];
278358
const gateway = {

packages/core/src/usage/usage-monitor.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
22
import { TypedEventEmitter } from "@posthog/shared";
33
import { inject, injectable, postConstruct, preDestroy } from "inversify";
4+
import type { AuthService } from "../auth/auth";
5+
import { AUTH_SERVICE } from "../auth/auth.module";
6+
import { AuthServiceEvent } from "../auth/schemas";
47
import { isCodeUsageFreeTier } from "../billing/usageDisplay";
58
import { USAGE_HOST, type UsageHost, type UsageLogger } from "./identifiers";
69
import {
@@ -34,10 +37,21 @@ export class UsageMonitorService extends TypedEventEmitter<UsageMonitorEvents> {
3437
private readonly host: UsageHost,
3538
@inject(ROOT_LOGGER)
3639
logger: RootLogger,
40+
@inject(AUTH_SERVICE)
41+
authService: AuthService,
3742
) {
3843
super();
3944
this.log = logger.scope("usage-monitor");
4045
this.thresholdsSeen = { ...this.host.getThresholdsSeen() };
46+
// The snapshot is identity-scoped billing data: a signed-in account must
47+
// never be served the previous account's spend.
48+
let orgId = authService.getState().currentOrgId;
49+
authService.on(AuthServiceEvent.StateChanged, (state) => {
50+
if (state.currentOrgId === orgId) return;
51+
orgId = state.currentOrgId;
52+
this.latestUsage = null;
53+
if (state.currentOrgId !== null) this.requestRefresh();
54+
});
4155
}
4256

4357
private readonly log: UsageLogger;
@@ -251,6 +265,8 @@ function isSameUsage(a: UsageOutput | null, b: UsageOutput): boolean {
251265
a.billing_period_end === b.billing_period_end &&
252266
a.code_usage_subscribed === b.code_usage_subscribed &&
253267
a.ai_credits?.exhausted === b.ai_credits?.exhausted &&
268+
a.ai_credits?.used_usd === b.ai_credits?.used_usd &&
269+
a.ai_credits?.limit_usd === b.ai_credits?.limit_usd &&
254270
isSameBucket(a.burst, b.burst) &&
255271
isSameBucket(a.sustained, b.sustained)
256272
);

packages/ui/src/features/auth/useAuthSession.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useHostTRPCClient } from "@posthog/host-router/react";
22
import { BILLING_FLAG } from "@posthog/shared";
33
import { useSeatStore } from "@posthog/ui/features/billing/seatStore";
4+
import { USAGE_QUERY_KEY } from "@posthog/ui/features/billing/useUsage";
45
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
56
import {
67
identifyUser,
@@ -113,6 +114,9 @@ function useSeatSync(
113114
): void {
114115
const queryClient = useQueryClient();
115116
useEffect(() => {
117+
// Usage is identity-scoped billing data — drop the cached snapshot so a
118+
// new sign-in never renders the previous account's spend.
119+
queryClient.removeQueries({ queryKey: USAGE_QUERY_KEY });
116120
if (!authIdentity || !billingEnabled) {
117121
useSeatStore.getState().reset();
118122
return;

packages/ui/src/features/billing/billingAnnouncementStore.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@ export const useBillingAnnouncementStore = create<BillingAnnouncementState>()(
2424
storage: electronStorage,
2525
partialize: (state) => ({ acknowledged: state.acknowledged }),
2626
onRehydrateStorage: () => (state) => {
27-
state?.setHasHydrated(true);
27+
if (state) {
28+
state.setHasHydrated(true);
29+
return;
30+
}
31+
// Failed storage read: fail open as unacknowledged — re-showing the
32+
// one-time announcement beats never showing it, and the in-memory
33+
// acknowledgment still dismisses it for the rest of the session.
34+
useBillingAnnouncementStore.setState({ _hasHydrated: true });
2835
},
2936
},
3037
),

packages/ui/src/features/billing/useUsage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useHostTRPCClient } from "@posthog/host-router/react";
22
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
33
import { useCallback, useEffect } from "react";
44

5-
const USAGE_QUERY_KEY = ["billing", "usage", "latest"] as const;
5+
export const USAGE_QUERY_KEY = ["billing", "usage", "latest"] as const;
66

77
export function useUsage({ enabled = true }: { enabled?: boolean } = {}) {
88
const client = useHostTRPCClient();

0 commit comments

Comments
 (0)