Skip to content

Commit c0b6e33

Browse files
bchapuisclaude
andcommitted
Remove plan column from users and derive plan from org subscription status
Plan was stored on the users table but the actual source of truth was always the organization's Stripe subscription status. This removes the redundant column and derives plan via resolveOrganizationPlan() everywhere. Also fixes endpoint-execute.ts which had plan hardcoded to "pro" and eliminates duplicate plan resolution logic in the billing route. To manually grant pro access without a Stripe subscription, set subscription_status = 'active' on the organization. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 95afea8 commit c0b6e33

20 files changed

Lines changed: 100 additions & 125 deletions

apps/api/src/auth.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,6 @@ auth.post("/refresh", async (c) => {
459459
name: result.name,
460460
email: result.email ?? undefined,
461461
avatarUrl: result.avatarUrl ?? undefined,
462-
plan: result.plan,
463462
role: result.role,
464463
developerMode: result.developerMode,
465464
provider,
@@ -552,7 +551,6 @@ auth.get(
552551
name: userName,
553552
email: userEmail ?? undefined,
554553
avatarUrl,
555-
plan: savedUser.plan,
556554
role: savedUser.role,
557555
developerMode: savedUser.developerMode,
558556
provider: "github",
@@ -631,7 +629,6 @@ auth.get(
631629
name: userName,
632630
email: userEmail,
633631
avatarUrl: avatarUrl ?? undefined,
634-
plan: savedUser.plan,
635632
role: savedUser.role,
636633
developerMode: savedUser.developerMode,
637634
provider: "google",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Remove plan from users (now derived from organization subscription info)
2+
DROP INDEX IF EXISTS users_plan_idx;
3+
ALTER TABLE users DROP COLUMN plan;

apps/api/src/db/queries.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,6 @@ import {
4848
OrganizationRole,
4949
type OrganizationRoleType,
5050
organizations,
51-
Plan,
52-
type PlanType,
5351
type QueueInsert,
5452
type QueueRow,
5553
type QueueTriggerInsert,
@@ -92,7 +90,6 @@ export type UserData = {
9290
name: string;
9391
email?: string;
9492
avatarUrl?: string;
95-
plan?: string;
9693
role?: string;
9794
};
9895

@@ -169,7 +166,6 @@ export async function saveUser(
169166
googleId: provider === "google" ? providerId : undefined,
170167
avatarUrl: avatarUrl,
171168
organizationId: organizationId,
172-
plan: (userData.plan as PlanType) || Plan.TRIAL,
173169
role: (userData.role as UserRoleType) || UserRole.USER,
174170
createdAt: now,
175171
updatedAt: now,
@@ -2159,7 +2155,7 @@ export async function getOrganizationBillingInfo(
21592155
* Derive user plan from organization billing info.
21602156
* Pro if has active subscription OR canceled but still in billing period.
21612157
*/
2162-
export function resolveUserPlan(billingInfo: {
2158+
export function resolveOrganizationPlan(billingInfo: {
21632159
subscriptionStatus: string | null;
21642160
currentPeriodEnd: Date | null;
21652161
}): string {

apps/api/src/db/schema/index.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,6 @@ import {
1212
* ENUMS & CONSTANTS
1313
*/
1414

15-
// Subscription plan types
16-
export const Plan = {
17-
TRIAL: "trial",
18-
FREE: "free",
19-
PRO: "pro",
20-
} as const;
21-
22-
export type PlanType = (typeof Plan)[keyof typeof Plan];
23-
2415
// User permission roles
2516
export const UserRole = {
2617
USER: "user",
@@ -178,7 +169,6 @@ export const users = sqliteTable(
178169
organizationId: text("organization_id")
179170
.notNull()
180171
.references(() => organizations.id, { onDelete: "cascade" }),
181-
plan: text("plan").$type<PlanType>().notNull().default(Plan.TRIAL),
182172
role: text("role").$type<UserRoleType>().notNull().default(UserRole.USER),
183173
developerMode: integer("developer_mode", { mode: "boolean" })
184174
.notNull()
@@ -195,7 +185,6 @@ export const users = sqliteTable(
195185
index("users_organization_id_idx").on(table.organizationId),
196186
index("users_email_idx").on(table.email),
197187
index("users_name_idx").on(table.name),
198-
index("users_plan_idx").on(table.plan),
199188
index("users_role_idx").on(table.role),
200189
index("users_developer_mode_idx").on(table.developerMode),
201190
index("users_created_at_idx").on(table.createdAt),

apps/api/src/email.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Bindings } from "./context";
44
import {
55
createDatabase,
66
getOrganizationBillingInfo,
7-
resolveUserPlan,
7+
resolveOrganizationPlan,
88
} from "./db";
99
import { getAgentByName } from "./durable-objects/agent-utils";
1010
import { createWorkerRuntime } from "./runtime/cloudflare-worker-runtime";
@@ -179,7 +179,7 @@ async function triggerWorkflowForEmail({
179179
userId: "email_trigger",
180180
organizationId,
181181
computeCredits: billingInfo.computeCredits,
182-
userPlan: resolveUserPlan(billingInfo),
182+
userPlan: resolveOrganizationPlan(billingInfo),
183183
workflow: {
184184
id: workflow.id,
185185
name: workflow.name,

apps/api/src/queue.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createDatabase } from "./db";
44
import {
55
getOrganizationBillingInfo,
66
getQueueTriggersByQueue,
7-
resolveUserPlan,
7+
resolveOrganizationPlan,
88
} from "./db/queries";
99
import { getAgentByName } from "./durable-objects/agent-utils";
1010
import { createWorkerRuntime } from "./runtime/cloudflare-worker-runtime";
@@ -42,7 +42,7 @@ async function executeWorkflow(
4242
userId: "queue_trigger",
4343
organizationId: workflowInfo.organizationId,
4444
computeCredits: billingInfo.computeCredits,
45-
userPlan: resolveUserPlan(billingInfo),
45+
userPlan: resolveOrganizationPlan(billingInfo),
4646
workflow: {
4747
id: workflowInfo.id,
4848
name: workflowData.name,

apps/api/src/routes/admin/users.ts

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { Hono } from "hono";
44
import { z } from "zod";
55

66
import { ApiContext } from "../../context";
7-
import { createDatabase, memberships, organizations, users } from "../../db";
7+
import {
8+
createDatabase,
9+
memberships,
10+
organizations,
11+
resolveOrganizationPlan,
12+
users,
13+
} from "../../db";
814

915
const adminUsersRoutes = new Hono<ApiContext>();
1016

@@ -40,25 +46,37 @@ adminUsersRoutes.get(
4046
.from(users)
4147
.where(whereClause);
4248

43-
// Get paginated users
44-
const usersList = await db
49+
// Get paginated users with org billing info to derive plan
50+
const rows = await db
4551
.select({
4652
id: users.id,
4753
name: users.name,
4854
email: users.email,
4955
avatarUrl: users.avatarUrl,
50-
plan: users.plan,
56+
subscriptionStatus: organizations.subscriptionStatus,
57+
currentPeriodEnd: organizations.currentPeriodEnd,
5158
role: users.role,
5259
developerMode: users.developerMode,
5360
createdAt: users.createdAt,
5461
updatedAt: users.updatedAt,
5562
})
5663
.from(users)
64+
.innerJoin(organizations, eq(users.organizationId, organizations.id))
5765
.where(whereClause)
5866
.orderBy(desc(users.createdAt))
5967
.limit(limit)
6068
.offset(offset);
6169

70+
const usersList = rows.map(
71+
({ subscriptionStatus, currentPeriodEnd, ...user }) => ({
72+
...user,
73+
plan: resolveOrganizationPlan({
74+
subscriptionStatus,
75+
currentPeriodEnd,
76+
}),
77+
})
78+
);
79+
6280
return c.json({
6381
users: usersList,
6482
pagination: {
@@ -85,13 +103,37 @@ adminUsersRoutes.get("/:id", async (c) => {
85103
const userId = c.req.param("id");
86104

87105
try {
88-
// Get user details
89-
const [user] = await db.select().from(users).where(eq(users.id, userId));
106+
// Get user details with org billing info to derive plan
107+
const [row] = await db
108+
.select({
109+
id: users.id,
110+
name: users.name,
111+
email: users.email,
112+
avatarUrl: users.avatarUrl,
113+
githubId: users.githubId,
114+
googleId: users.googleId,
115+
subscriptionStatus: organizations.subscriptionStatus,
116+
currentPeriodEnd: organizations.currentPeriodEnd,
117+
role: users.role,
118+
developerMode: users.developerMode,
119+
tourCompleted: users.tourCompleted,
120+
createdAt: users.createdAt,
121+
updatedAt: users.updatedAt,
122+
})
123+
.from(users)
124+
.innerJoin(organizations, eq(users.organizationId, organizations.id))
125+
.where(eq(users.id, userId));
90126

91-
if (!user) {
127+
if (!row) {
92128
return c.json({ error: "User not found" }, 404);
93129
}
94130

131+
const { subscriptionStatus, currentPeriodEnd, ...userFields } = row;
132+
const user = {
133+
...userFields,
134+
plan: resolveOrganizationPlan({ subscriptionStatus, currentPeriodEnd }),
135+
};
136+
95137
// Get user's organization memberships
96138
const userMemberships = await db
97139
.select({
@@ -108,20 +150,7 @@ adminUsersRoutes.get("/:id", async (c) => {
108150
.where(eq(memberships.userId, userId));
109151

110152
return c.json({
111-
user: {
112-
id: user.id,
113-
name: user.name,
114-
email: user.email,
115-
avatarUrl: user.avatarUrl,
116-
githubId: user.githubId,
117-
googleId: user.googleId,
118-
plan: user.plan,
119-
role: user.role,
120-
developerMode: user.developerMode,
121-
tourCompleted: user.tourCompleted,
122-
createdAt: user.createdAt,
123-
updatedAt: user.updatedAt,
124-
},
153+
user,
125154
memberships: userMemberships,
126155
});
127156
} catch (error) {

apps/api/src/routes/billing.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { z } from "zod";
1414
import { jwtMiddleware } from "../auth";
1515
import { PRO_INCLUDED_CREDITS, TRIAL_CREDITS } from "../constants/billing";
1616
import type { ApiContext } from "../context";
17-
import { createDatabase, organizations } from "../db";
17+
import { createDatabase, organizations, resolveOrganizationPlan } from "../db";
1818
import { createStripeService } from "../services/stripe-service";
1919
import { getOrganizationComputeUsage } from "../utils/credits";
2020

@@ -47,15 +47,10 @@ billing.get("/", async (c) => {
4747
organizationId
4848
);
4949

50-
// Determine plan - Pro if has active subscription OR canceled but still in period
51-
const hasProAccess =
52-
org.subscriptionStatus === "active" ||
53-
(org.subscriptionStatus === "canceled" &&
54-
org.currentPeriodEnd &&
55-
new Date(org.currentPeriodEnd) > new Date());
56-
const plan = hasProAccess ? "pro" : "trial";
50+
// Resolve plan from organization billing info
51+
const plan = resolveOrganizationPlan(org);
5752
// Use constants for included credits (database value may be outdated)
58-
const includedCredits = hasProAccess ? PRO_INCLUDED_CREDITS : TRIAL_CREDITS;
53+
const includedCredits = plan === "pro" ? PRO_INCLUDED_CREDITS : TRIAL_CREDITS;
5954

6055
const response: GetBillingResponse = {
6156
billing: {

apps/api/src/routes/discord-webhook.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
getDiscordBotById,
99
getDiscordTriggersByBot,
1010
getOrganizationBillingInfo,
11-
resolveUserPlan,
11+
resolveOrganizationPlan,
1212
} from "../db";
1313
import { getAgentByName } from "../durable-objects/agent-utils";
1414
import { createWorkerRuntime } from "../runtime/cloudflare-worker-runtime";
@@ -299,7 +299,7 @@ async function executeWorkflow(
299299
userId: "discord_trigger",
300300
organizationId,
301301
computeCredits: billingInfo.computeCredits,
302-
userPlan: resolveUserPlan(billingInfo),
302+
userPlan: resolveOrganizationPlan(billingInfo),
303303
workflow: {
304304
id: workflow.id,
305305
name: workflow.name,

apps/api/src/routes/endpoint-execute.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getEndpointById,
88
getEndpointTriggersByEndpoint,
99
getOrganizationBillingInfo,
10+
resolveOrganizationPlan,
1011
verifyApiKey,
1112
} from "../db";
1213
import { createRateLimitMiddleware } from "../middleware/rate-limit";
@@ -115,7 +116,7 @@ endpointExecuteRoutes.on(
115116
subscriptionStatus: subscriptionStatus ?? undefined,
116117
overageLimit: overageLimit ?? null,
117118
parameters,
118-
userPlan: "pro",
119+
userPlan: resolveOrganizationPlan(billingInfo),
119120
env: c.env,
120121
});
121122

0 commit comments

Comments
 (0)