Skip to content

Commit 3a3c946

Browse files
improvement(teams-plan): seats increase simplification + not triggering checkout session (#2117)
* improvement(teams-plan): seats increase simplification + not triggering checkout session * cleanup via helper
1 parent 7b7586d commit 3a3c946

File tree

6 files changed

+391
-44
lines changed

6 files changed

+391
-44
lines changed

apps/sim/app/api/organizations/[id]/route.ts

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { getSession } from '@/lib/auth'
77
import {
88
getOrganizationSeatAnalytics,
99
getOrganizationSeatInfo,
10-
updateOrganizationSeats,
1110
} from '@/lib/billing/validation/seat-management'
1211
import { createLogger } from '@/lib/logs/console/logger'
1312

@@ -25,7 +24,6 @@ const updateOrganizationSchema = z.object({
2524
)
2625
.optional(),
2726
logo: z.string().nullable().optional(),
28-
seats: z.number().int().min(1, 'Invalid seat count').optional(),
2927
})
3028

3129
/**
@@ -116,7 +114,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
116114

117115
/**
118116
* PUT /api/organizations/[id]
119-
* Update organization settings or seat count
117+
* Update organization settings (name, slug, logo)
118+
* Note: For seat updates, use PUT /api/organizations/[id]/seats instead
120119
*/
121120
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
122121
try {
@@ -135,7 +134,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
135134
return NextResponse.json({ error: firstError.message }, { status: 400 })
136135
}
137136

138-
const { name, slug, logo, seats } = validation.data
137+
const { name, slug, logo } = validation.data
139138

140139
// Verify user has admin access
141140
const memberEntry = await db
@@ -155,31 +154,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
155154
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
156155
}
157156

158-
// Handle seat count update
159-
if (seats !== undefined) {
160-
const result = await updateOrganizationSeats(organizationId, seats, session.user.id)
161-
162-
if (!result.success) {
163-
return NextResponse.json({ error: result.error }, { status: 400 })
164-
}
165-
166-
logger.info('Organization seat count updated', {
167-
organizationId,
168-
newSeatCount: seats,
169-
updatedBy: session.user.id,
170-
})
171-
172-
return NextResponse.json({
173-
success: true,
174-
message: 'Seat count updated successfully',
175-
data: {
176-
seats: seats,
177-
updatedBy: session.user.id,
178-
updatedAt: new Date().toISOString(),
179-
},
180-
})
181-
}
182-
183157
// Handle settings update
184158
if (name !== undefined || slug !== undefined || logo !== undefined) {
185159
// Check if slug is already taken by another organization
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { db } from '@sim/db'
2+
import { member, subscription } from '@sim/db/schema'
3+
import { and, eq } from 'drizzle-orm'
4+
import { type NextRequest, NextResponse } from 'next/server'
5+
import { z } from 'zod'
6+
import { getSession } from '@/lib/auth'
7+
import { requireStripeClient } from '@/lib/billing/stripe-client'
8+
import { isBillingEnabled } from '@/lib/environment'
9+
import { createLogger } from '@/lib/logs/console/logger'
10+
11+
const logger = createLogger('OrganizationSeatsAPI')
12+
13+
const updateSeatsSchema = z.object({
14+
seats: z.number().int().min(1, 'Minimum 1 seat required').max(50, 'Maximum 50 seats allowed'),
15+
})
16+
17+
/**
18+
* PUT /api/organizations/[id]/seats
19+
* Update organization seat count using Stripe's subscription.update API.
20+
* This is the recommended approach for per-seat billing changes.
21+
*/
22+
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
23+
try {
24+
const session = await getSession()
25+
26+
if (!session?.user?.id) {
27+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
28+
}
29+
30+
if (!isBillingEnabled) {
31+
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 })
32+
}
33+
34+
const { id: organizationId } = await params
35+
const body = await request.json()
36+
37+
const validation = updateSeatsSchema.safeParse(body)
38+
if (!validation.success) {
39+
const firstError = validation.error.errors[0]
40+
return NextResponse.json({ error: firstError.message }, { status: 400 })
41+
}
42+
43+
const { seats: newSeatCount } = validation.data
44+
45+
// Verify user has admin access to this organization
46+
const memberEntry = await db
47+
.select()
48+
.from(member)
49+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
50+
.limit(1)
51+
52+
if (memberEntry.length === 0) {
53+
return NextResponse.json(
54+
{ error: 'Forbidden - Not a member of this organization' },
55+
{ status: 403 }
56+
)
57+
}
58+
59+
if (!['owner', 'admin'].includes(memberEntry[0].role)) {
60+
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
61+
}
62+
63+
// Get the organization's subscription
64+
const subscriptionRecord = await db
65+
.select()
66+
.from(subscription)
67+
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
68+
.limit(1)
69+
70+
if (subscriptionRecord.length === 0) {
71+
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
72+
}
73+
74+
const orgSubscription = subscriptionRecord[0]
75+
76+
// Only team plans support seat changes (not enterprise - those are handled manually)
77+
if (orgSubscription.plan !== 'team') {
78+
return NextResponse.json(
79+
{ error: 'Seat changes are only available for Team plans' },
80+
{ status: 400 }
81+
)
82+
}
83+
84+
if (!orgSubscription.stripeSubscriptionId) {
85+
return NextResponse.json(
86+
{ error: 'No Stripe subscription found for this organization' },
87+
{ status: 400 }
88+
)
89+
}
90+
91+
// Validate that we're not reducing below current member count
92+
const memberCount = await db
93+
.select({ userId: member.userId })
94+
.from(member)
95+
.where(eq(member.organizationId, organizationId))
96+
97+
if (newSeatCount < memberCount.length) {
98+
return NextResponse.json(
99+
{
100+
error: `Cannot reduce seats below current member count (${memberCount.length})`,
101+
currentMembers: memberCount.length,
102+
},
103+
{ status: 400 }
104+
)
105+
}
106+
107+
const currentSeats = orgSubscription.seats || 1
108+
109+
// If no change, return early
110+
if (newSeatCount === currentSeats) {
111+
return NextResponse.json({
112+
success: true,
113+
message: 'No change in seat count',
114+
data: {
115+
seats: currentSeats,
116+
stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
117+
},
118+
})
119+
}
120+
121+
const stripe = requireStripeClient()
122+
123+
// Get the Stripe subscription to find the subscription item ID
124+
const stripeSubscription = await stripe.subscriptions.retrieve(
125+
orgSubscription.stripeSubscriptionId
126+
)
127+
128+
if (stripeSubscription.status !== 'active') {
129+
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
130+
}
131+
132+
// Find the subscription item (there should be only one for team plans)
133+
const subscriptionItem = stripeSubscription.items.data[0]
134+
135+
if (!subscriptionItem) {
136+
return NextResponse.json(
137+
{ error: 'No subscription item found in Stripe subscription' },
138+
{ status: 500 }
139+
)
140+
}
141+
142+
logger.info('Updating Stripe subscription quantity', {
143+
organizationId,
144+
stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
145+
subscriptionItemId: subscriptionItem.id,
146+
currentSeats,
147+
newSeatCount,
148+
userId: session.user.id,
149+
})
150+
151+
// Update the subscription item quantity using Stripe's recommended approach
152+
// This will automatically prorate the billing
153+
const updatedSubscription = await stripe.subscriptions.update(
154+
orgSubscription.stripeSubscriptionId,
155+
{
156+
items: [
157+
{
158+
id: subscriptionItem.id,
159+
quantity: newSeatCount,
160+
},
161+
],
162+
proration_behavior: 'create_prorations', // Stripe's default - charge/credit immediately
163+
}
164+
)
165+
166+
// Update our local database to reflect the change
167+
// Note: This will also be updated via webhook, but we update immediately for UX
168+
await db
169+
.update(subscription)
170+
.set({
171+
seats: newSeatCount,
172+
})
173+
.where(eq(subscription.id, orgSubscription.id))
174+
175+
logger.info('Successfully updated seat count', {
176+
organizationId,
177+
stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
178+
oldSeats: currentSeats,
179+
newSeats: newSeatCount,
180+
updatedBy: session.user.id,
181+
prorationBehavior: 'create_prorations',
182+
})
183+
184+
return NextResponse.json({
185+
success: true,
186+
message:
187+
newSeatCount > currentSeats
188+
? `Added ${newSeatCount - currentSeats} seat(s). Your billing has been adjusted.`
189+
: `Removed ${currentSeats - newSeatCount} seat(s). You'll receive a prorated credit.`,
190+
data: {
191+
seats: newSeatCount,
192+
previousSeats: currentSeats,
193+
stripeSubscriptionId: updatedSubscription.id,
194+
stripeStatus: updatedSubscription.status,
195+
},
196+
})
197+
} catch (error) {
198+
const { id: organizationId } = await params
199+
200+
// Handle Stripe-specific errors
201+
if (error instanceof Error && 'type' in error) {
202+
const stripeError = error as any
203+
logger.error('Stripe error updating seats', {
204+
organizationId,
205+
type: stripeError.type,
206+
code: stripeError.code,
207+
message: stripeError.message,
208+
})
209+
210+
return NextResponse.json(
211+
{
212+
error: stripeError.message || 'Failed to update seats in Stripe',
213+
code: stripeError.code,
214+
},
215+
{ status: 400 }
216+
)
217+
}
218+
219+
logger.error('Failed to update organization seats', {
220+
organizationId,
221+
error,
222+
})
223+
224+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
225+
}
226+
}
227+
228+
/**
229+
* GET /api/organizations/[id]/seats
230+
* Get current seat information for an organization
231+
*/
232+
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
233+
try {
234+
const session = await getSession()
235+
236+
if (!session?.user?.id) {
237+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
238+
}
239+
240+
const { id: organizationId } = await params
241+
242+
// Verify user has access to this organization
243+
const memberEntry = await db
244+
.select()
245+
.from(member)
246+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
247+
.limit(1)
248+
249+
if (memberEntry.length === 0) {
250+
return NextResponse.json(
251+
{ error: 'Forbidden - Not a member of this organization' },
252+
{ status: 403 }
253+
)
254+
}
255+
256+
// Get subscription data
257+
const subscriptionRecord = await db
258+
.select()
259+
.from(subscription)
260+
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
261+
.limit(1)
262+
263+
if (subscriptionRecord.length === 0) {
264+
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
265+
}
266+
267+
// Get member count
268+
const memberCount = await db
269+
.select({ userId: member.userId })
270+
.from(member)
271+
.where(eq(member.organizationId, organizationId))
272+
273+
const orgSubscription = subscriptionRecord[0]
274+
const maxSeats = orgSubscription.seats || 1
275+
const usedSeats = memberCount.length
276+
const availableSeats = Math.max(0, maxSeats - usedSeats)
277+
278+
return NextResponse.json({
279+
success: true,
280+
data: {
281+
maxSeats,
282+
usedSeats,
283+
availableSeats,
284+
plan: orgSubscription.plan,
285+
canModifySeats: orgSubscription.plan === 'team',
286+
},
287+
})
288+
} catch (error) {
289+
const { id: organizationId } = await params
290+
logger.error('Failed to get organization seats', {
291+
organizationId,
292+
error,
293+
})
294+
295+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
296+
}
297+
}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,6 @@ export function TeamManagement() {
233233
await updateSeatsMutation.mutateAsync({
234234
orgId: activeOrganization?.id,
235235
seats: currentSeats - 1,
236-
subscriptionId: subscriptionData.id,
237236
})
238237
} catch (error) {
239238
logger.error('Failed to reduce seats', error)
@@ -258,7 +257,6 @@ export function TeamManagement() {
258257
await updateSeatsMutation.mutateAsync({
259258
orgId: activeOrganization?.id,
260259
seats: seatsToUse,
261-
subscriptionId: subscriptionData.id,
262260
})
263261
setIsAddSeatDialogOpen(false)
264262
} catch (error) {

0 commit comments

Comments
 (0)