Skip to content

Commit 728ac79

Browse files
authored
feat: Implement billing with seat management, plan/usage settings and onboarding flow (#1304)
1 parent e274340 commit 728ac79

50 files changed

Lines changed: 1738 additions & 665 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/code/src/main/db/repositories/auth-session-repository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CloudRegion } from "@shared/types/oauth";
1+
import type { CloudRegion } from "@shared/types/regions";
22
import { eq } from "drizzle-orm";
33
import { inject, injectable } from "inversify";
44
import { MAIN_TOKENS } from "../../di/tokens";

apps/code/src/main/services/auth/service.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import {
2-
getCloudUrlFromRegion,
3-
OAUTH_SCOPE_VERSION,
4-
} from "@shared/constants/oauth";
5-
import type { CloudRegion } from "@shared/types/oauth";
1+
import { OAUTH_SCOPE_VERSION } from "@shared/constants/oauth";
2+
import type { CloudRegion } from "@shared/types/regions";
63
import { type BackoffOptions, sleepWithBackoff } from "@shared/utils/backoff";
4+
import { getCloudUrlFromRegion } from "@shared/utils/urls";
75
import { powerMonitor } from "electron";
86
import { inject, injectable, postConstruct, preDestroy } from "inversify";
97
import type { IAuthPreferenceRepository } from "../../db/repositories/auth-preference-repository";

apps/code/src/main/services/github-integration/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getCloudUrlFromRegion } from "@shared/constants/oauth";
1+
import { getCloudUrlFromRegion } from "@shared/utils/urls";
22
import { shell } from "electron";
33
import { injectable } from "inversify";
44
import { logger } from "../../utils/logger";

apps/code/src/main/services/linear-integration/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getCloudUrlFromRegion } from "@shared/constants/oauth.js";
1+
import { getCloudUrlFromRegion } from "@shared/utils/urls.js";
22
import { shell } from "electron";
33
import { injectable } from "inversify";
44
import { logger } from "../../utils/logger.js";

apps/code/src/main/services/llm-gateway/schemas.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,20 @@ export interface AnthropicErrorResponse {
5656
code?: string;
5757
};
5858
}
59+
60+
export const usageBucketSchema = z.object({
61+
used_percent: z.number(),
62+
resets_in_seconds: z.number(),
63+
exceeded: z.boolean(),
64+
});
65+
66+
export const usageOutput = z.object({
67+
product: z.string(),
68+
user_id: z.number(),
69+
sustained: usageBucketSchema,
70+
burst: usageBucketSchema,
71+
is_rate_limited: z.boolean(),
72+
});
73+
74+
export type UsageBucket = z.infer<typeof usageBucketSchema>;
75+
export type UsageOutput = z.infer<typeof usageOutput>;

apps/code/src/main/services/llm-gateway/service.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
import { getLlmGatewayUrl } from "@posthog/agent/posthog-api";
1+
import {
2+
getGatewayUsageUrl,
3+
getLlmGatewayUrl,
4+
} from "@posthog/agent/posthog-api";
25
import { net } from "electron";
36
import { inject, injectable } from "inversify";
47
import { MAIN_TOKENS } from "../../di/tokens";
58
import { logger } from "../../utils/logger";
69
import type { AuthService } from "../auth/service";
7-
import type {
8-
AnthropicErrorResponse,
9-
AnthropicMessagesRequest,
10-
AnthropicMessagesResponse,
11-
LlmMessage,
12-
PromptOutput,
10+
import {
11+
type AnthropicErrorResponse,
12+
type AnthropicMessagesRequest,
13+
type AnthropicMessagesResponse,
14+
type LlmMessage,
15+
type PromptOutput,
16+
type UsageOutput,
17+
usageOutput,
1318
} from "./schemas";
1419

1520
const log = logger.scope("llm-gateway");
@@ -134,4 +139,27 @@ export class LlmGatewayService {
134139
},
135140
};
136141
}
142+
143+
async fetchUsage(): Promise<UsageOutput> {
144+
const auth = await this.authService.getValidAccessToken();
145+
const usageUrl = getGatewayUsageUrl(auth.apiHost);
146+
147+
log.debug("Fetching usage from gateway", { url: usageUrl });
148+
149+
const response = await this.authService.authenticatedFetch(
150+
net.fetch,
151+
usageUrl,
152+
);
153+
154+
if (!response.ok) {
155+
throw new LlmGatewayError(
156+
`Failed to fetch usage: HTTP ${response.status}`,
157+
"usage_error",
158+
undefined,
159+
response.status,
160+
);
161+
}
162+
163+
return usageOutput.parse(await response.json());
164+
}
137165
}

apps/code/src/main/services/oauth/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import * as crypto from "node:crypto";
22
import * as http from "node:http";
33
import type { Socket } from "node:net";
44
import {
5-
getCloudUrlFromRegion,
65
getOauthClientIdFromRegion,
76
OAUTH_SCOPES,
87
} from "@shared/constants/oauth";
8+
import { getCloudUrlFromRegion } from "@shared/utils/urls";
99
import { shell } from "electron";
1010
import { inject, injectable } from "inversify";
1111
import { MAIN_TOKENS } from "../../di/tokens";

apps/code/src/main/trpc/routers/llm-gateway.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { container } from "../../di/container";
22
import { MAIN_TOKENS } from "../../di/tokens";
3-
import { promptInput, promptOutput } from "../../services/llm-gateway/schemas";
3+
import {
4+
promptInput,
5+
promptOutput,
6+
usageOutput,
7+
} from "../../services/llm-gateway/schemas";
48
import type { LlmGatewayService } from "../../services/llm-gateway/service";
59
import { publicProcedure, router } from "../trpc";
610

@@ -18,4 +22,8 @@ export const llmGatewayRouter = router({
1822
model: input.model,
1923
}),
2024
),
25+
26+
usage: publicProcedure
27+
.output(usageOutput)
28+
.query(() => getService().fetchUsage()),
2129
});

apps/code/src/renderer/api/posthogClient.ts

Lines changed: 158 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort";
2-
import { type PermissionMode } from "@posthog/agent/execution-mode";
2+
import type { PermissionMode } from "@posthog/agent/execution-mode";
33
import type {
44
ActionabilityJudgmentArtefact,
55
AvailableSuggestedReviewer,
@@ -24,11 +24,29 @@ import type {
2424
TaskRun,
2525
} from "@shared/types";
2626
import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud";
27+
import type { SeatData } from "@shared/types/seat";
28+
import { SEAT_PRODUCT_KEY } from "@shared/types/seat";
2729
import type { StoredLogEntry } from "@shared/types/session-events";
2830
import { logger } from "@utils/logger";
2931
import { buildApiFetcher } from "./fetcher";
3032
import { createApiClient, type Schemas } from "./generated";
3133

34+
export class SeatSubscriptionRequiredError extends Error {
35+
redirectUrl: string;
36+
constructor(redirectUrl: string) {
37+
super("Billing subscription required");
38+
this.name = "SeatSubscriptionRequiredError";
39+
this.redirectUrl = redirectUrl;
40+
}
41+
}
42+
43+
export class SeatPaymentFailedError extends Error {
44+
constructor(message?: string) {
45+
super(message ?? "Payment failed");
46+
this.name = "SeatPaymentFailedError";
47+
}
48+
}
49+
3250
const log = logger.scope("posthog-client");
3351

3452
export type McpRecommendedServer = Schemas.RecommendedServer;
@@ -1178,39 +1196,6 @@ export class PostHogAPIClient {
11781196
return await response.json();
11791197
}
11801198

1181-
/**
1182-
* Get billing information for a specific organization.
1183-
*/
1184-
async getOrgBilling(orgId: string): Promise<{
1185-
has_active_subscription: boolean;
1186-
customer_id: string | null;
1187-
}> {
1188-
const url = new URL(
1189-
`${this.api.baseUrl}/api/organizations/${orgId}/billing/`,
1190-
);
1191-
const response = await this.api.fetcher.fetch({
1192-
method: "get",
1193-
url,
1194-
path: `/api/organizations/${orgId}/billing/`,
1195-
});
1196-
1197-
if (!response.ok) {
1198-
throw new Error(
1199-
`Failed to fetch organization billing: ${response.statusText}`,
1200-
);
1201-
}
1202-
1203-
const data = await response.json();
1204-
return {
1205-
has_active_subscription:
1206-
typeof data.has_active_subscription === "boolean"
1207-
? data.has_active_subscription
1208-
: false,
1209-
customer_id:
1210-
typeof data.customer_id === "string" ? data.customer_id : null,
1211-
};
1212-
}
1213-
12141199
async getSignalReports(
12151200
params?: SignalReportsQueryParams,
12161201
): Promise<SignalReportsResponse> {
@@ -1741,6 +1726,145 @@ export class PostHogAPIClient {
17411726
}
17421727
}
17431728

1729+
async getMySeat(): Promise<SeatData | null> {
1730+
try {
1731+
const url = new URL(`${this.api.baseUrl}/api/seats/me/`);
1732+
url.searchParams.set("product_key", SEAT_PRODUCT_KEY);
1733+
const response = await this.api.fetcher.fetch({
1734+
method: "get",
1735+
url,
1736+
path: "/api/seats/me/",
1737+
});
1738+
return (await response.json()) as SeatData;
1739+
} catch (error) {
1740+
if (this.isFetcherStatusError(error, 404)) {
1741+
return null;
1742+
}
1743+
throw error;
1744+
}
1745+
}
1746+
1747+
async createSeat(planKey: string): Promise<SeatData> {
1748+
try {
1749+
const user = await this.getCurrentUser();
1750+
const distinctId = user.distinct_id;
1751+
if (!distinctId) {
1752+
throw new Error("Cannot create seat: user has no distinct_id");
1753+
}
1754+
const url = new URL(`${this.api.baseUrl}/api/seats/`);
1755+
const response = await this.api.fetcher.fetch({
1756+
method: "post",
1757+
url,
1758+
path: "/api/seats/",
1759+
overrides: {
1760+
body: JSON.stringify({
1761+
product_key: SEAT_PRODUCT_KEY,
1762+
plan_key: planKey,
1763+
user_distinct_id: distinctId,
1764+
}),
1765+
},
1766+
});
1767+
return (await response.json()) as SeatData;
1768+
} catch (error) {
1769+
this.throwSeatError(error);
1770+
}
1771+
}
1772+
1773+
async upgradeSeat(planKey: string): Promise<SeatData> {
1774+
try {
1775+
const url = new URL(`${this.api.baseUrl}/api/seats/me/`);
1776+
const response = await this.api.fetcher.fetch({
1777+
method: "patch",
1778+
url,
1779+
path: "/api/seats/me/",
1780+
overrides: {
1781+
body: JSON.stringify({
1782+
product_key: SEAT_PRODUCT_KEY,
1783+
plan_key: planKey,
1784+
}),
1785+
},
1786+
});
1787+
return (await response.json()) as SeatData;
1788+
} catch (error) {
1789+
this.throwSeatError(error);
1790+
}
1791+
}
1792+
1793+
async cancelSeat(): Promise<void> {
1794+
try {
1795+
const url = new URL(`${this.api.baseUrl}/api/seats/me/`);
1796+
url.searchParams.set("product_key", SEAT_PRODUCT_KEY);
1797+
await this.api.fetcher.fetch({
1798+
method: "delete",
1799+
url,
1800+
path: "/api/seats/me/",
1801+
});
1802+
} catch (error) {
1803+
if (this.isFetcherStatusError(error, 204)) {
1804+
return;
1805+
}
1806+
this.throwSeatError(error);
1807+
}
1808+
}
1809+
1810+
async reactivateSeat(): Promise<SeatData> {
1811+
try {
1812+
const url = new URL(`${this.api.baseUrl}/api/seats/me/reactivate/`);
1813+
const response = await this.api.fetcher.fetch({
1814+
method: "post",
1815+
url,
1816+
path: "/api/seats/me/reactivate/",
1817+
overrides: {
1818+
body: JSON.stringify({ product_key: SEAT_PRODUCT_KEY }),
1819+
},
1820+
});
1821+
return (await response.json()) as SeatData;
1822+
} catch (error) {
1823+
this.throwSeatError(error);
1824+
}
1825+
}
1826+
1827+
private isFetcherStatusError(error: unknown, status: number): boolean {
1828+
return error instanceof Error && error.message.includes(`[${status}]`);
1829+
}
1830+
1831+
private parseFetcherError(error: unknown): {
1832+
status: number;
1833+
body: Record<string, unknown>;
1834+
} | null {
1835+
if (!(error instanceof Error)) return null;
1836+
const match = error.message.match(/\[(\d+)\]\s*(.*)/);
1837+
if (!match) return null;
1838+
try {
1839+
return {
1840+
status: Number.parseInt(match[1], 10),
1841+
body: JSON.parse(match[2]) as Record<string, unknown>,
1842+
};
1843+
} catch {
1844+
return { status: Number.parseInt(match[1], 10), body: {} };
1845+
}
1846+
}
1847+
1848+
private throwSeatError(error: unknown): never {
1849+
const parsed = this.parseFetcherError(error);
1850+
1851+
if (parsed) {
1852+
if (
1853+
parsed.status === 400 &&
1854+
typeof parsed.body.redirect_url === "string"
1855+
) {
1856+
throw new SeatSubscriptionRequiredError(parsed.body.redirect_url);
1857+
}
1858+
if (parsed.status === 402) {
1859+
const message =
1860+
typeof parsed.body.error === "string" ? parsed.body.error : undefined;
1861+
throw new SeatPaymentFailedError(message);
1862+
}
1863+
}
1864+
1865+
throw error;
1866+
}
1867+
17441868
/**
17451869
* Check if a feature flag is enabled for the current project.
17461870
* Returns true if the flag exists and is active, false otherwise.

apps/code/src/renderer/features/auth/components/AuthScreen.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { Callout, Flex, Spinner, Text, Theme } from "@radix-ui/themes";
99
import codeLogo from "@renderer/assets/images/code.svg";
1010
import logomark from "@renderer/assets/images/logomark.svg";
1111
import { trpcClient } from "@renderer/trpc/client";
12-
import { REGION_LABELS } from "@shared/constants/oauth";
13-
import type { CloudRegion } from "@shared/types/oauth";
12+
import type { CloudRegion } from "@shared/types/regions";
13+
import { REGION_LABELS } from "@shared/types/regions";
1414
import { RegionSelect } from "./RegionSelect";
1515

1616
export const getErrorMessage = (error: unknown) => {

0 commit comments

Comments
 (0)