Skip to content

Commit fd1ee43

Browse files
authored
feat(billing): classify gateway billing denials and rework limit modal for usage-based billing
First of the usage-based billing cutover stack (errors/copy -> billing surfaces -> model gating): - classifyGatewayLimitError/extractGatedModel in @posthog/shared; the free-tier model-gate 403 no longer classifies as a fatal session error. - sessionService routes gateway billing denials to the usage-limit modal with a cause; the modal renders cause-aware usage-based copy behind the new posthog-code-usage-billing flag (off = seat-era copy unchanged). - Helper prompts route to the free-tier model for orgs known unbilled via the optional code_usage_billed usage field, with a single gate-403 retry as the cold-start backstop. - Seat handling keys off the API's 410 seat_product_retired: auto-provision stops re-attempting and explicit upgrades surface a clear message. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
1 parent 73b695c commit fd1ee43

19 files changed

Lines changed: 862 additions & 84 deletions

packages/api-client/src/posthog-client.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ export class SeatPaymentFailedError extends Error {
133133
}
134134
}
135135

136+
/**
137+
* Seat creation, upgrades, and reactivation return 410 Gone once PostHog Code
138+
* seats are retired in favor of usage-based billing (reads and cancellation
139+
* keep working for existing seats).
140+
*/
141+
export class SeatProductRetiredError extends Error {
142+
constructor() {
143+
super("PostHog Code seats have been retired");
144+
this.name = "SeatProductRetiredError";
145+
}
146+
}
147+
136148
export class SandboxCustomImagesDisabledError extends Error {
137149
constructor(message?: string) {
138150
super(message ?? "Custom sandbox images are not enabled");
@@ -4589,6 +4601,9 @@ export class PostHogAPIClient {
45894601
const parsed = this.parseFetcherError(error);
45904602

45914603
if (parsed) {
4604+
if (parsed.status === 410) {
4605+
throw new SeatProductRetiredError();
4606+
}
45924607
if (
45934608
parsed.status === 400 &&
45944609
typeof parsed.body.redirect_url === "string"

packages/core/src/billing/seatErrors.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,27 @@ export interface ClassifiedSeatError {
33
redirectUrl: string | null;
44
}
55

6+
/**
7+
* The seat API's 410 Gone: PostHog Code seats are retired in favor of
8+
* usage-based billing. Name-based so it survives the SeatClient seam.
9+
*/
10+
export function isSeatProductRetiredError(error: unknown): boolean {
11+
return error instanceof Error && error.name === "SeatProductRetiredError";
12+
}
13+
614
export function classifySeatError(error: unknown): ClassifiedSeatError {
715
if (!(error instanceof Error)) {
816
return { error: "An unexpected error occurred", redirectUrl: null };
917
}
1018

19+
if (isSeatProductRetiredError(error)) {
20+
return {
21+
error:
22+
"PostHog Code seat plans have been retired — usage is now billed to your organization.",
23+
redirectUrl: null,
24+
};
25+
}
26+
1127
if (error.name === "SeatSubscriptionRequiredError") {
1228
const redirectUrl =
1329
"redirectUrl" in error && typeof error.redirectUrl === "string"

packages/core/src/billing/seatService.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ class SeatPaymentFailedError extends Error {
6262
}
6363
}
6464

65+
class SeatProductRetiredError extends Error {
66+
constructor() {
67+
super("PostHog Code seats have been retired");
68+
this.name = "SeatProductRetiredError";
69+
}
70+
}
71+
6572
beforeEach(() => {
6673
vi.clearAllMocks();
6774
});
@@ -278,3 +285,57 @@ describe("error classification", () => {
278285
expect(result.error).toBe("Card declined");
279286
});
280287
});
288+
289+
// The seat API 410s creation/upgrades/reactivation once Code seats are
290+
// retired in favor of usage-based billing (reads keep working).
291+
describe("seat product retired (410 Gone)", () => {
292+
it("treats a retired auto-provision as seatless instead of erroring", async () => {
293+
const client = makeClient({
294+
createSeat: vi.fn().mockRejectedValue(new SeatProductRetiredError()),
295+
});
296+
const result = await new SeatService(client, logger).fetchSeat({
297+
autoProvision: true,
298+
});
299+
expect(result.seat).toBeNull();
300+
expect(result.error).toBeNull();
301+
});
302+
303+
it("stops re-attempting provisioning once the product is known retired", async () => {
304+
const createSeat = vi.fn().mockRejectedValue(new SeatProductRetiredError());
305+
const client = makeClient({ createSeat });
306+
const service = new SeatService(client, logger);
307+
308+
await service.fetchSeat({ autoProvision: true });
309+
createSeat.mockClear();
310+
await service.fetchSeat({ autoProvision: true });
311+
312+
expect(createSeat).not.toHaveBeenCalled();
313+
});
314+
315+
it("still re-fetches the seat after a non-retirement provisioning failure", async () => {
316+
const seat = makeSeat();
317+
const getMySeat = vi
318+
.fn()
319+
.mockResolvedValueOnce(null)
320+
.mockResolvedValueOnce(null)
321+
.mockResolvedValue(seat);
322+
const client = makeClient({
323+
getMySeat,
324+
createSeat: vi.fn().mockRejectedValue(new Error("conflict")),
325+
});
326+
const result = await new SeatService(client, logger).fetchSeat({
327+
autoProvision: true,
328+
});
329+
expect(result.error).toBeNull();
330+
expect(result.seat).toEqual(seat);
331+
});
332+
333+
it("surfaces retirement as a clear error on an explicit upgrade", async () => {
334+
const client = makeClient({
335+
createSeat: vi.fn().mockRejectedValue(new SeatProductRetiredError()),
336+
});
337+
const result = await new SeatService(client, logger).upgradeToPro();
338+
expect(result.error).toContain("retired");
339+
expect(result.redirectUrl).toBeNull();
340+
});
341+
});

packages/core/src/billing/seatService.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
22
import { PLAN_FREE, PLAN_PRO, type SeatData } from "@posthog/shared";
33
import { inject, injectable } from "inversify";
44
import { SEAT_CLIENT, type SeatClient, type SeatLogger } from "./identifiers";
5-
import { type ClassifiedSeatError, classifySeatError } from "./seatErrors";
5+
import {
6+
type ClassifiedSeatError,
7+
classifySeatError,
8+
isSeatProductRetiredError,
9+
} from "./seatErrors";
610

711
export interface SeatOperationResult {
812
seat: SeatData | null;
@@ -43,6 +47,11 @@ function fail(classified: ClassifiedSeatError): SeatOperationResult {
4347
export class SeatService {
4448
private readonly logger: SeatLogger;
4549

50+
// The seat API 410s creation/upgrades/reactivation once seats are retired
51+
// (usage-based billing). Remembered so auth/onboarding fetches stop
52+
// re-attempting a provision that can never succeed.
53+
private seatProductRetired = false;
54+
4655
constructor(
4756
@inject(SEAT_CLIENT) private readonly client: SeatClient,
4857
@inject(ROOT_LOGGER) logger: RootLogger,
@@ -55,13 +64,18 @@ export class SeatService {
5564
autoProvision: boolean;
5665
}): Promise<SeatData | null> {
5766
let seat = await this.client.getMySeat({ best: options.best });
58-
if (!seat && options.autoProvision) {
67+
if (!seat && options.autoProvision && !this.seatProductRetired) {
5968
this.logger.info("No seat found, auto-provisioning free plan", {
6069
best: options.best,
6170
});
6271
try {
6372
seat = await this.client.createSeat(PLAN_FREE);
64-
} catch {
73+
} catch (error) {
74+
if (isSeatProductRetiredError(error)) {
75+
this.seatProductRetired = true;
76+
this.logger.info("Seat product retired; skipping auto-provision");
77+
return null;
78+
}
6579
this.logger.info("Auto-provision failed, re-fetching seat");
6680
seat = await this.client.getMySeat({ best: options.best });
6781
}
@@ -118,6 +132,7 @@ export class SeatService {
118132
this.client.invalidatePlanCache();
119133
return ok(seat, null, true);
120134
} catch (error) {
135+
if (isSeatProductRetiredError(error)) this.seatProductRetired = true;
121136
this.logger.error("provisionFreeSeat failed", error);
122137
return fail(classifySeatError(error));
123138
}
@@ -143,6 +158,7 @@ export class SeatService {
143158
this.client.invalidatePlanCache();
144159
return ok(seat, seat);
145160
} catch (error) {
161+
if (isSeatProductRetiredError(error)) this.seatProductRetired = true;
146162
return fail(classifySeatError(error));
147163
}
148164
}
@@ -168,6 +184,7 @@ export class SeatService {
168184
this.client.invalidatePlanCache();
169185
return ok(seat, seat);
170186
} catch (error) {
187+
if (isSeatProductRetiredError(error)) this.seatProductRetired = true;
171188
return fail(classifySeatError(error));
172189
}
173190
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
deriveUsageLimitCause,
4+
seatEraLimitContent,
5+
usageBasedLimitContent,
6+
} from "./usageLimitContent";
7+
8+
describe("deriveUsageLimitCause", () => {
9+
it.each([
10+
["model_gate", "burst", "model_gate"],
11+
[null, "burst", "user_daily_limit"],
12+
[null, "sustained", "user_monthly_limit"],
13+
// No cause and no bucket (e.g. an upstream provider's own rate limit):
14+
// stay generic instead of blaming the org's billing.
15+
[null, null, null],
16+
] as const)("cause %s + bucket %s -> %s", (cause, bucket, expected) => {
17+
expect(deriveUsageLimitCause(cause, bucket)).toBe(expected);
18+
});
19+
});
20+
21+
describe("usageBasedLimitContent", () => {
22+
it("names the gated model and offers a payment method", () => {
23+
const content = usageBasedLimitContent({
24+
cause: "model_gate",
25+
model: "Claude Opus 4.8",
26+
resetLabel: null,
27+
billed: false,
28+
});
29+
expect(content.title).toBe("Unlock premium models");
30+
expect(content.description).toContain("Claude Opus 4.8 isn't");
31+
expect(content.actionLabel).toBe("Add payment method");
32+
});
33+
34+
it("falls back to generic wording when the gated model is unknown", () => {
35+
const content = usageBasedLimitContent({
36+
cause: "model_gate",
37+
model: null,
38+
resetLabel: null,
39+
billed: undefined,
40+
});
41+
expect(content.description).toContain("This model isn't");
42+
});
43+
44+
it.each([
45+
// Confirmed-free org: allocation used up, the fix is adding a card.
46+
[false, "Free usage used up", "Add payment method"],
47+
// Billed org: the fix is raising the spend limit.
48+
[true, "Organization usage limit reached", "Manage billing"],
49+
// Unknown billed state must not read as free.
50+
[undefined, "Organization usage limit reached", "Manage billing"],
51+
] as const)(
52+
"org_limit with billed=%s -> %s / %s",
53+
(billed, title, actionLabel) => {
54+
const content = usageBasedLimitContent({
55+
cause: "org_limit",
56+
model: null,
57+
resetLabel: null,
58+
billed,
59+
});
60+
expect(content.title).toBe(title);
61+
expect(content.actionLabel).toBe(actionLabel);
62+
},
63+
);
64+
65+
it.each([
66+
["user_daily_limit", "Free daily limit reached"],
67+
["user_monthly_limit", "Free monthly limit reached"],
68+
] as const)("%s carries the reset hint", (cause, title) => {
69+
const content = usageBasedLimitContent({
70+
cause,
71+
model: null,
72+
resetLabel: "Resets in 2h",
73+
billed: false,
74+
});
75+
expect(content.title).toBe(title);
76+
expect(content.description).toContain("Resets in 2h");
77+
expect(content.actionLabel).toBe("Add payment method");
78+
});
79+
80+
it("renders generic copy without a billing CTA when the cause is unknown", () => {
81+
const content = usageBasedLimitContent({
82+
cause: null,
83+
model: null,
84+
resetLabel: null,
85+
billed: true,
86+
});
87+
expect(content.title).toBe("Usage limit reached");
88+
expect(content.actionLabel).toBeNull();
89+
expect(content.dismissLabel).toBe("Got it");
90+
});
91+
});
92+
93+
describe("seatEraLimitContent", () => {
94+
it("keeps the Pro cap copy with no upgrade action", () => {
95+
const content = seatEraLimitContent({
96+
bucket: "sustained",
97+
isPro: true,
98+
resetLabel: "Resets in 3d",
99+
});
100+
expect(content.title).toBe("Monthly limit reached");
101+
expect(content.description).toContain("monthly usage cap");
102+
expect(content.actionLabel).toBeNull();
103+
expect(content.dismissLabel).toBe("Got it");
104+
});
105+
106+
it("keeps the free-plan upgrade pitch", () => {
107+
const content = seatEraLimitContent({
108+
bucket: "burst",
109+
isPro: false,
110+
resetLabel: null,
111+
});
112+
expect(content.title).toBe("Daily limit reached");
113+
expect(content.description).toContain("Upgrade to Pro for 40×");
114+
expect(content.actionLabel).toBe("See Pro");
115+
});
116+
});

0 commit comments

Comments
 (0)