Skip to content

Commit 7ac4739

Browse files
committed
address comments
1 parent de647dd commit 7ac4739

3 files changed

Lines changed: 181 additions & 17 deletions

File tree

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

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
removeExternalUserFromOrganizationWorkspaces,
1313
removeUserFromOrganization,
1414
} from '@/lib/billing/organizations/membership'
15+
import { reduceOrganizationSeatsByOne } from '@/lib/billing/organizations/seats'
1516
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1617

1718
const logger = createLogger('OrganizationMemberAPI')
@@ -285,6 +286,7 @@ export const DELETE = withRouteHandler(
285286
}
286287

287288
const { id: organizationId, memberId: targetUserId } = await params
289+
const shouldReduceSeats = request.nextUrl.searchParams.get('shouldReduceSeats') === 'true'
288290

289291
const userMember = await db
290292
.select()
@@ -330,10 +332,15 @@ export const DELETE = withRouteHandler(
330332
})
331333

332334
if (!externalResult.success) {
333-
return NextResponse.json(
334-
{ error: externalResult.error || 'External workspace member not found' },
335-
{ status: externalResult.error === 'External workspace member not found' ? 404 : 500 }
336-
)
335+
const error = externalResult.error || 'External workspace member not found'
336+
const status =
337+
error === 'External workspace member not found'
338+
? 404
339+
: error === 'User is an organization member'
340+
? 409
341+
: 500
342+
343+
return NextResponse.json({ error }, { status })
337344
}
338345

339346
logger.info('External workspace member removed from organization workspaces', {
@@ -400,6 +407,24 @@ export const DELETE = withRouteHandler(
400407
return NextResponse.json({ error: result.error }, { status: 500 })
401408
}
402409

410+
let seatReduction: Awaited<ReturnType<typeof reduceOrganizationSeatsByOne>> | null = null
411+
if (shouldReduceSeats && session.user.id !== targetUserId) {
412+
try {
413+
seatReduction = await reduceOrganizationSeatsByOne(organizationId, session.user.id)
414+
} catch (seatError) {
415+
logger.error('Failed to reduce seats after member removal', {
416+
organizationId,
417+
removedMemberId: targetUserId,
418+
removedBy: session.user.id,
419+
error: seatError,
420+
})
421+
seatReduction = {
422+
reduced: false,
423+
reason: 'Failed to reduce seats after member removal',
424+
}
425+
}
426+
}
427+
403428
if (session.user.id === targetUserId) {
404429
try {
405430
await setActiveOrganizationForCurrentSession(null)
@@ -418,6 +443,7 @@ export const DELETE = withRouteHandler(
418443
removedBy: session.user.id,
419444
wasSelfRemoval: session.user.id === targetUserId,
420445
billingActions: result.billingActions,
446+
seatReduction,
421447
})
422448

423449
recordAudit({
@@ -437,6 +463,7 @@ export const DELETE = withRouteHandler(
437463
targetEmail: targetMember[0].email ?? undefined,
438464
targetName: targetMember[0].name ?? undefined,
439465
wasSelfRemoval: session.user.id === targetUserId,
466+
seatReduction,
440467
},
441468
request,
442469
})
@@ -451,6 +478,7 @@ export const DELETE = withRouteHandler(
451478
removedMemberId: targetUserId,
452479
removedBy: session.user.id,
453480
removedAt: new Date().toISOString(),
481+
seatReduction,
454482
},
455483
})
456484
} catch (error) {

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -223,17 +223,6 @@ export function TeamManagement() {
223223
shouldReduceSeats,
224224
})
225225

226-
if (shouldReduceSeats && totalSeats > 1) {
227-
try {
228-
await updateSeatsMutation.mutateAsync({
229-
orgId: activeOrganization.id,
230-
seats: totalSeats - 1,
231-
})
232-
} catch (seatError) {
233-
logger.error('Failed to reduce seats after removing member', seatError)
234-
}
235-
}
236-
237226
setRemoveMemberDialog({
238227
open: false,
239228
memberId: '',
@@ -255,8 +244,6 @@ export function TeamManagement() {
255244
session?.user?.id,
256245
activeOrganization?.id,
257246
removeMemberMutation,
258-
totalSeats,
259-
updateSeatsMutation,
260247
]
261248
)
262249

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { db } from '@sim/db'
2+
import { invitation, member, subscription } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, count, eq, gt, inArray, ne } from 'drizzle-orm'
5+
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
6+
import { isTeam } from '@/lib/billing/plan-helpers'
7+
import { requireStripeClient } from '@/lib/billing/stripe-client'
8+
import {
9+
hasUsableSubscriptionStatus,
10+
USABLE_SUBSCRIPTION_STATUSES,
11+
} from '@/lib/billing/subscriptions/utils'
12+
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
13+
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
14+
15+
const logger = createLogger('OrganizationSeats')
16+
17+
export interface ReduceOrganizationSeatsResult {
18+
reduced: boolean
19+
previousSeats?: number
20+
seats?: number
21+
reason?: string
22+
}
23+
24+
export async function reduceOrganizationSeatsByOne(
25+
organizationId: string,
26+
userId: string
27+
): Promise<ReduceOrganizationSeatsResult> {
28+
if (!isBillingEnabled) {
29+
return { reduced: false, reason: 'Billing is not enabled' }
30+
}
31+
32+
const [orgSubscription] = await db
33+
.select()
34+
.from(subscription)
35+
.where(
36+
and(
37+
eq(subscription.referenceId, organizationId),
38+
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
39+
)
40+
)
41+
.limit(1)
42+
43+
if (!orgSubscription) {
44+
return { reduced: false, reason: 'No active subscription found' }
45+
}
46+
47+
if (await isOrganizationBillingBlocked(organizationId)) {
48+
return { reduced: false, reason: 'An active subscription is required' }
49+
}
50+
51+
if (!isTeam(orgSubscription.plan)) {
52+
return { reduced: false, reason: 'Seat changes are only available for Team plans' }
53+
}
54+
55+
if (!orgSubscription.stripeSubscriptionId) {
56+
return { reduced: false, reason: 'No Stripe subscription found for this organization' }
57+
}
58+
59+
const currentSeats = orgSubscription.seats || 1
60+
if (currentSeats <= 1) {
61+
return {
62+
reduced: false,
63+
previousSeats: currentSeats,
64+
seats: currentSeats,
65+
reason: 'Minimum 1 seat required',
66+
}
67+
}
68+
69+
const [memberCountRow] = await db
70+
.select({ count: count() })
71+
.from(member)
72+
.where(eq(member.organizationId, organizationId))
73+
74+
const [pendingCountRow] = await db
75+
.select({ count: count() })
76+
.from(invitation)
77+
.where(
78+
and(
79+
eq(invitation.organizationId, organizationId),
80+
eq(invitation.status, 'pending'),
81+
ne(invitation.membershipIntent, 'external'),
82+
gt(invitation.expiresAt, new Date())
83+
)
84+
)
85+
86+
const occupiedSeats = (memberCountRow?.count ?? 0) + (pendingCountRow?.count ?? 0)
87+
const nextSeats = currentSeats - 1
88+
89+
if (nextSeats < occupiedSeats) {
90+
return {
91+
reduced: false,
92+
previousSeats: currentSeats,
93+
seats: currentSeats,
94+
reason: `Cannot reduce seats below current occupancy (${occupiedSeats}).`,
95+
}
96+
}
97+
98+
const stripe = requireStripeClient()
99+
const stripeSubscription = await stripe.subscriptions.retrieve(
100+
orgSubscription.stripeSubscriptionId
101+
)
102+
103+
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
104+
return {
105+
reduced: false,
106+
previousSeats: currentSeats,
107+
seats: currentSeats,
108+
reason: 'Stripe subscription is not active',
109+
}
110+
}
111+
112+
const subscriptionItem = stripeSubscription.items.data[0]
113+
if (!subscriptionItem) {
114+
return {
115+
reduced: false,
116+
previousSeats: currentSeats,
117+
seats: currentSeats,
118+
reason: 'No subscription item found in Stripe subscription',
119+
}
120+
}
121+
122+
const updatedSubscription = await stripe.subscriptions.update(
123+
orgSubscription.stripeSubscriptionId,
124+
{
125+
items: [
126+
{
127+
id: subscriptionItem.id,
128+
quantity: nextSeats,
129+
},
130+
],
131+
proration_behavior: 'always_invoice',
132+
},
133+
{
134+
idempotencyKey: `seats-reduce-member-removal:${orgSubscription.stripeSubscriptionId}:${nextSeats}`,
135+
}
136+
)
137+
138+
const updatedSeats = updatedSubscription.items.data[0]?.quantity ?? nextSeats
139+
await syncSeatsFromStripeQuantity(orgSubscription.id, orgSubscription.seats, updatedSeats)
140+
141+
logger.info('Reduced organization seats after member removal', {
142+
organizationId,
143+
userId,
144+
previousSeats: currentSeats,
145+
seats: updatedSeats,
146+
})
147+
148+
return { reduced: true, previousSeats: currentSeats, seats: updatedSeats }
149+
}

0 commit comments

Comments
 (0)