Skip to content

Commit 507fc11

Browse files
authored
feat(admin): added more billing, subscriptions, and organization admin API routes (#2225)
* feat(admin): added more billing, subscriptions, and organization admin API routes * cleanup * ack PR comments * cleanup * ack PR comment
1 parent 26670e2 commit 507fc11

File tree

15 files changed

+2505
-211
lines changed

15 files changed

+2505
-211
lines changed

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

Lines changed: 24 additions & 195 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
11
import { db } from '@sim/db'
2-
import {
3-
member,
4-
organization,
5-
subscription as subscriptionTable,
6-
user,
7-
userStats,
8-
} from '@sim/db/schema'
9-
import { and, eq, sql } from 'drizzle-orm'
2+
import { member, user, userStats } from '@sim/db/schema'
3+
import { and, eq } from 'drizzle-orm'
104
import { type NextRequest, NextResponse } from 'next/server'
115
import { z } from 'zod'
126
import { getSession } from '@/lib/auth'
137
import { getUserUsageData } from '@/lib/billing/core/usage'
14-
import { requireStripeClient } from '@/lib/billing/stripe-client'
8+
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
159
import { createLogger } from '@/lib/logs/console/logger'
1610

1711
const logger = createLogger('OrganizationMemberAPI')
@@ -41,7 +35,6 @@ export async function GET(
4135
const url = new URL(request.url)
4236
const includeUsage = url.searchParams.get('include') === 'usage'
4337

44-
// Verify user has access to this organization
4538
const userMember = await db
4639
.select()
4740
.from(member)
@@ -58,7 +51,6 @@ export async function GET(
5851
const userRole = userMember[0].role
5952
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
6053

61-
// Get target member details
6254
const memberQuery = db
6355
.select({
6456
id: member.id,
@@ -80,7 +72,6 @@ export async function GET(
8072
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
8173
}
8274

83-
// Check if user can view this member's details
8475
const canViewDetails = hasAdminAccess || session.user.id === memberId
8576

8677
if (!canViewDetails) {
@@ -89,7 +80,6 @@ export async function GET(
8980

9081
let memberData = memberEntry[0]
9182

92-
// Include usage data if requested and user has permission
9383
if (includeUsage && hasAdminAccess) {
9484
const usageData = await db
9585
.select({
@@ -181,7 +171,6 @@ export async function PUT(
181171
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
182172
}
183173

184-
// Check if target member exists
185174
const targetMember = await db
186175
.select()
187176
.from(member)
@@ -192,25 +181,21 @@ export async function PUT(
192181
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
193182
}
194183

195-
// Prevent changing owner role
196184
if (targetMember[0].role === 'owner') {
197185
return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 })
198186
}
199187

200-
// Prevent non-owners from promoting to admin
201188
if (role === 'admin' && userMember[0].role !== 'owner') {
202189
return NextResponse.json(
203190
{ error: 'Only owners can promote members to admin' },
204191
{ status: 403 }
205192
)
206193
}
207194

208-
// Prevent admins from changing other admins' roles - only owners can modify admin roles
209195
if (targetMember[0].role === 'admin' && userMember[0].role !== 'owner') {
210196
return NextResponse.json({ error: 'Only owners can change admin roles' }, { status: 403 })
211197
}
212198

213-
// Update member role
214199
const updatedMember = await db
215200
.update(member)
216201
.set({ role })
@@ -264,9 +249,8 @@ export async function DELETE(
264249
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
265250
}
266251

267-
const { id: organizationId, memberId } = await params
252+
const { id: organizationId, memberId: targetUserId } = await params
268253

269-
// Verify user has admin access
270254
const userMember = await db
271255
.select()
272256
.from(member)
@@ -281,209 +265,54 @@ export async function DELETE(
281265
}
282266

283267
const canRemoveMembers =
284-
['owner', 'admin'].includes(userMember[0].role) || session.user.id === memberId
268+
['owner', 'admin'].includes(userMember[0].role) || session.user.id === targetUserId
285269

286270
if (!canRemoveMembers) {
287271
return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 })
288272
}
289273

290-
// Check if target member exists
291274
const targetMember = await db
292-
.select()
275+
.select({ id: member.id, role: member.role })
293276
.from(member)
294-
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
277+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, targetUserId)))
295278
.limit(1)
296279

297280
if (targetMember.length === 0) {
298281
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
299282
}
300283

301-
// Prevent removing the owner
302-
if (targetMember[0].role === 'owner') {
303-
return NextResponse.json({ error: 'Cannot remove organization owner' }, { status: 400 })
304-
}
305-
306-
// Capture departed member's usage and reset their cost to prevent double billing
307-
try {
308-
const departingUserStats = await db
309-
.select({ currentPeriodCost: userStats.currentPeriodCost })
310-
.from(userStats)
311-
.where(eq(userStats.userId, memberId))
312-
.limit(1)
284+
const result = await removeUserFromOrganization({
285+
userId: targetUserId,
286+
organizationId,
287+
memberId: targetMember[0].id,
288+
})
313289

314-
if (departingUserStats.length > 0 && departingUserStats[0].currentPeriodCost) {
315-
const usage = Number.parseFloat(departingUserStats[0].currentPeriodCost)
316-
if (usage > 0) {
317-
await db
318-
.update(organization)
319-
.set({
320-
departedMemberUsage: sql`${organization.departedMemberUsage} + ${usage}`,
321-
})
322-
.where(eq(organization.id, organizationId))
323-
324-
await db
325-
.update(userStats)
326-
.set({ currentPeriodCost: '0' })
327-
.where(eq(userStats.userId, memberId))
328-
329-
logger.info('Captured departed member usage and reset user cost', {
330-
organizationId,
331-
memberId,
332-
usage,
333-
})
334-
}
290+
if (!result.success) {
291+
if (result.error === 'Cannot remove organization owner') {
292+
return NextResponse.json({ error: result.error }, { status: 400 })
335293
}
336-
} catch (usageCaptureError) {
337-
logger.error('Failed to capture departed member usage', {
338-
organizationId,
339-
memberId,
340-
error: usageCaptureError,
341-
})
342-
}
343-
344-
// Remove member
345-
const removedMember = await db
346-
.delete(member)
347-
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
348-
.returning()
349-
350-
if (removedMember.length === 0) {
351-
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
294+
if (result.error === 'Member not found') {
295+
return NextResponse.json({ error: result.error }, { status: 404 })
296+
}
297+
return NextResponse.json({ error: result.error }, { status: 500 })
352298
}
353299

354300
logger.info('Organization member removed', {
355301
organizationId,
356-
removedMemberId: memberId,
302+
removedMemberId: targetUserId,
357303
removedBy: session.user.id,
358-
wasSelfRemoval: session.user.id === memberId,
304+
wasSelfRemoval: session.user.id === targetUserId,
305+
billingActions: result.billingActions,
359306
})
360307

361-
// If the removed user left their last paid team and has a personal Pro set to cancel_at_period_end, restore it
362-
try {
363-
const remainingPaidTeams = await db
364-
.select({ orgId: member.organizationId })
365-
.from(member)
366-
.where(eq(member.userId, memberId))
367-
368-
let hasAnyPaidTeam = false
369-
if (remainingPaidTeams.length > 0) {
370-
const orgIds = remainingPaidTeams.map((m) => m.orgId)
371-
const orgPaidSubs = await db
372-
.select()
373-
.from(subscriptionTable)
374-
.where(and(eq(subscriptionTable.status, 'active'), eq(subscriptionTable.plan, 'team')))
375-
376-
hasAnyPaidTeam = orgPaidSubs.some((s) => orgIds.includes(s.referenceId))
377-
}
378-
379-
if (!hasAnyPaidTeam) {
380-
const personalProRows = await db
381-
.select()
382-
.from(subscriptionTable)
383-
.where(
384-
and(
385-
eq(subscriptionTable.referenceId, memberId),
386-
eq(subscriptionTable.status, 'active'),
387-
eq(subscriptionTable.plan, 'pro')
388-
)
389-
)
390-
.limit(1)
391-
392-
const personalPro = personalProRows[0]
393-
if (
394-
personalPro &&
395-
personalPro.cancelAtPeriodEnd === true &&
396-
personalPro.stripeSubscriptionId
397-
) {
398-
try {
399-
const stripe = requireStripeClient()
400-
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
401-
cancel_at_period_end: false,
402-
})
403-
} catch (stripeError) {
404-
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
405-
userId: memberId,
406-
stripeSubscriptionId: personalPro.stripeSubscriptionId,
407-
error: stripeError,
408-
})
409-
}
410-
411-
try {
412-
await db
413-
.update(subscriptionTable)
414-
.set({ cancelAtPeriodEnd: false })
415-
.where(eq(subscriptionTable.id, personalPro.id))
416-
417-
logger.info('Restored personal Pro after leaving last paid team', {
418-
userId: memberId,
419-
personalSubscriptionId: personalPro.id,
420-
})
421-
} catch (dbError) {
422-
logger.error('DB update failed when restoring personal Pro', {
423-
userId: memberId,
424-
subscriptionId: personalPro.id,
425-
error: dbError,
426-
})
427-
}
428-
429-
// Also restore the snapshotted Pro usage back to currentPeriodCost
430-
try {
431-
const userStatsRows = await db
432-
.select({
433-
currentPeriodCost: userStats.currentPeriodCost,
434-
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
435-
})
436-
.from(userStats)
437-
.where(eq(userStats.userId, memberId))
438-
.limit(1)
439-
440-
if (userStatsRows.length > 0) {
441-
const currentUsage = userStatsRows[0].currentPeriodCost || '0'
442-
const snapshotUsage = userStatsRows[0].proPeriodCostSnapshot || '0'
443-
444-
const currentNum = Number.parseFloat(currentUsage)
445-
const snapshotNum = Number.parseFloat(snapshotUsage)
446-
const restoredUsage = (currentNum + snapshotNum).toString()
447-
448-
await db
449-
.update(userStats)
450-
.set({
451-
currentPeriodCost: restoredUsage,
452-
proPeriodCostSnapshot: '0', // Clear the snapshot
453-
})
454-
.where(eq(userStats.userId, memberId))
455-
456-
logger.info('Restored Pro usage after leaving team', {
457-
userId: memberId,
458-
previousUsage: currentUsage,
459-
snapshotUsage: snapshotUsage,
460-
restoredUsage: restoredUsage,
461-
})
462-
}
463-
} catch (usageRestoreError) {
464-
logger.error('Failed to restore Pro usage after leaving team', {
465-
userId: memberId,
466-
error: usageRestoreError,
467-
})
468-
}
469-
}
470-
}
471-
} catch (postRemoveError) {
472-
logger.error('Post-removal personal Pro restore check failed', {
473-
organizationId,
474-
memberId,
475-
error: postRemoveError,
476-
})
477-
}
478-
479308
return NextResponse.json({
480309
success: true,
481310
message:
482-
session.user.id === memberId
311+
session.user.id === targetUserId
483312
? 'You have left the organization'
484313
: 'Member removed successfully',
485314
data: {
486-
removedMemberId: memberId,
315+
removedMemberId: targetUserId,
487316
removedBy: session.user.id,
488317
removedAt: new Date().toISOString(),
489318
},

0 commit comments

Comments
 (0)