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
This commit is contained in:
Waleed
2025-12-06 14:13:50 -08:00
committed by GitHub
parent 26670e289d
commit 507fc112be
15 changed files with 2505 additions and 211 deletions

View File

@@ -1,17 +1,11 @@
import { db } from '@sim/db'
import {
member,
organization,
subscription as subscriptionTable,
user,
userStats,
} from '@sim/db/schema'
import { and, eq, sql } from 'drizzle-orm'
import { member, user, userStats } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OrganizationMemberAPI')
@@ -41,7 +35,6 @@ export async function GET(
const url = new URL(request.url)
const includeUsage = url.searchParams.get('include') === 'usage'
// Verify user has access to this organization
const userMember = await db
.select()
.from(member)
@@ -58,7 +51,6 @@ export async function GET(
const userRole = userMember[0].role
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
// Get target member details
const memberQuery = db
.select({
id: member.id,
@@ -80,7 +72,6 @@ export async function GET(
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Check if user can view this member's details
const canViewDetails = hasAdminAccess || session.user.id === memberId
if (!canViewDetails) {
@@ -89,7 +80,6 @@ export async function GET(
let memberData = memberEntry[0]
// Include usage data if requested and user has permission
if (includeUsage && hasAdminAccess) {
const usageData = await db
.select({
@@ -181,7 +171,6 @@ export async function PUT(
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
// Check if target member exists
const targetMember = await db
.select()
.from(member)
@@ -192,12 +181,10 @@ export async function PUT(
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Prevent changing owner role
if (targetMember[0].role === 'owner') {
return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 })
}
// Prevent non-owners from promoting to admin
if (role === 'admin' && userMember[0].role !== 'owner') {
return NextResponse.json(
{ error: 'Only owners can promote members to admin' },
@@ -205,12 +192,10 @@ export async function PUT(
)
}
// Prevent admins from changing other admins' roles - only owners can modify admin roles
if (targetMember[0].role === 'admin' && userMember[0].role !== 'owner') {
return NextResponse.json({ error: 'Only owners can change admin roles' }, { status: 403 })
}
// Update member role
const updatedMember = await db
.update(member)
.set({ role })
@@ -264,9 +249,8 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId, memberId } = await params
const { id: organizationId, memberId: targetUserId } = await params
// Verify user has admin access
const userMember = await db
.select()
.from(member)
@@ -281,209 +265,54 @@ export async function DELETE(
}
const canRemoveMembers =
['owner', 'admin'].includes(userMember[0].role) || session.user.id === memberId
['owner', 'admin'].includes(userMember[0].role) || session.user.id === targetUserId
if (!canRemoveMembers) {
return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 })
}
// Check if target member exists
const targetMember = await db
.select()
.select({ id: member.id, role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
.where(and(eq(member.organizationId, organizationId), eq(member.userId, targetUserId)))
.limit(1)
if (targetMember.length === 0) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Prevent removing the owner
if (targetMember[0].role === 'owner') {
return NextResponse.json({ error: 'Cannot remove organization owner' }, { status: 400 })
}
const result = await removeUserFromOrganization({
userId: targetUserId,
organizationId,
memberId: targetMember[0].id,
})
// Capture departed member's usage and reset their cost to prevent double billing
try {
const departingUserStats = await db
.select({ currentPeriodCost: userStats.currentPeriodCost })
.from(userStats)
.where(eq(userStats.userId, memberId))
.limit(1)
if (departingUserStats.length > 0 && departingUserStats[0].currentPeriodCost) {
const usage = Number.parseFloat(departingUserStats[0].currentPeriodCost)
if (usage > 0) {
await db
.update(organization)
.set({
departedMemberUsage: sql`${organization.departedMemberUsage} + ${usage}`,
})
.where(eq(organization.id, organizationId))
await db
.update(userStats)
.set({ currentPeriodCost: '0' })
.where(eq(userStats.userId, memberId))
logger.info('Captured departed member usage and reset user cost', {
organizationId,
memberId,
usage,
})
}
if (!result.success) {
if (result.error === 'Cannot remove organization owner') {
return NextResponse.json({ error: result.error }, { status: 400 })
}
} catch (usageCaptureError) {
logger.error('Failed to capture departed member usage', {
organizationId,
memberId,
error: usageCaptureError,
})
}
// Remove member
const removedMember = await db
.delete(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
.returning()
if (removedMember.length === 0) {
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
if (result.error === 'Member not found') {
return NextResponse.json({ error: result.error }, { status: 404 })
}
return NextResponse.json({ error: result.error }, { status: 500 })
}
logger.info('Organization member removed', {
organizationId,
removedMemberId: memberId,
removedMemberId: targetUserId,
removedBy: session.user.id,
wasSelfRemoval: session.user.id === memberId,
wasSelfRemoval: session.user.id === targetUserId,
billingActions: result.billingActions,
})
// If the removed user left their last paid team and has a personal Pro set to cancel_at_period_end, restore it
try {
const remainingPaidTeams = await db
.select({ orgId: member.organizationId })
.from(member)
.where(eq(member.userId, memberId))
let hasAnyPaidTeam = false
if (remainingPaidTeams.length > 0) {
const orgIds = remainingPaidTeams.map((m) => m.orgId)
const orgPaidSubs = await db
.select()
.from(subscriptionTable)
.where(and(eq(subscriptionTable.status, 'active'), eq(subscriptionTable.plan, 'team')))
hasAnyPaidTeam = orgPaidSubs.some((s) => orgIds.includes(s.referenceId))
}
if (!hasAnyPaidTeam) {
const personalProRows = await db
.select()
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, memberId),
eq(subscriptionTable.status, 'active'),
eq(subscriptionTable.plan, 'pro')
)
)
.limit(1)
const personalPro = personalProRows[0]
if (
personalPro &&
personalPro.cancelAtPeriodEnd === true &&
personalPro.stripeSubscriptionId
) {
try {
const stripe = requireStripeClient()
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
cancel_at_period_end: false,
})
} catch (stripeError) {
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
userId: memberId,
stripeSubscriptionId: personalPro.stripeSubscriptionId,
error: stripeError,
})
}
try {
await db
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: false })
.where(eq(subscriptionTable.id, personalPro.id))
logger.info('Restored personal Pro after leaving last paid team', {
userId: memberId,
personalSubscriptionId: personalPro.id,
})
} catch (dbError) {
logger.error('DB update failed when restoring personal Pro', {
userId: memberId,
subscriptionId: personalPro.id,
error: dbError,
})
}
// Also restore the snapshotted Pro usage back to currentPeriodCost
try {
const userStatsRows = await db
.select({
currentPeriodCost: userStats.currentPeriodCost,
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
})
.from(userStats)
.where(eq(userStats.userId, memberId))
.limit(1)
if (userStatsRows.length > 0) {
const currentUsage = userStatsRows[0].currentPeriodCost || '0'
const snapshotUsage = userStatsRows[0].proPeriodCostSnapshot || '0'
const currentNum = Number.parseFloat(currentUsage)
const snapshotNum = Number.parseFloat(snapshotUsage)
const restoredUsage = (currentNum + snapshotNum).toString()
await db
.update(userStats)
.set({
currentPeriodCost: restoredUsage,
proPeriodCostSnapshot: '0', // Clear the snapshot
})
.where(eq(userStats.userId, memberId))
logger.info('Restored Pro usage after leaving team', {
userId: memberId,
previousUsage: currentUsage,
snapshotUsage: snapshotUsage,
restoredUsage: restoredUsage,
})
}
} catch (usageRestoreError) {
logger.error('Failed to restore Pro usage after leaving team', {
userId: memberId,
error: usageRestoreError,
})
}
}
}
} catch (postRemoveError) {
logger.error('Post-removal personal Pro restore check failed', {
organizationId,
memberId,
error: postRemoveError,
})
}
return NextResponse.json({
success: true,
message:
session.user.id === memberId
session.user.id === targetUserId
? 'You have left the organization'
: 'Member removed successfully',
data: {
removedMemberId: memberId,
removedMemberId: targetUserId,
removedBy: session.user.id,
removedAt: new Date().toISOString(),
},

View File

@@ -7,20 +7,48 @@
* Set ADMIN_API_KEY environment variable and use x-admin-key header.
*
* Endpoints:
* GET /api/v1/admin/users - List all users
* GET /api/v1/admin/users/:id - Get user details
* GET /api/v1/admin/workspaces - List all workspaces
* GET /api/v1/admin/workspaces/:id - Get workspace details
* GET /api/v1/admin/workspaces/:id/workflows - List workspace workflows
* DELETE /api/v1/admin/workspaces/:id/workflows - Delete all workspace workflows
* GET /api/v1/admin/workspaces/:id/folders - List workspace folders
* GET /api/v1/admin/workspaces/:id/export - Export workspace (ZIP/JSON)
* POST /api/v1/admin/workspaces/:id/import - Import into workspace
* GET /api/v1/admin/workflows - List all workflows
* GET /api/v1/admin/workflows/:id - Get workflow details
* DELETE /api/v1/admin/workflows/:id - Delete workflow
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
* POST /api/v1/admin/workflows/import - Import single workflow
*
* Users:
* GET /api/v1/admin/users - List all users
* GET /api/v1/admin/users/:id - Get user details
* GET /api/v1/admin/users/:id/billing - Get user billing info
* PATCH /api/v1/admin/users/:id/billing - Update user billing (limit, blocked)
* POST /api/v1/admin/users/:id/billing/move-to-org - Move user to organization
*
* Workspaces:
* GET /api/v1/admin/workspaces - List all workspaces
* GET /api/v1/admin/workspaces/:id - Get workspace details
* GET /api/v1/admin/workspaces/:id/workflows - List workspace workflows
* DELETE /api/v1/admin/workspaces/:id/workflows - Delete all workspace workflows
* GET /api/v1/admin/workspaces/:id/folders - List workspace folders
* GET /api/v1/admin/workspaces/:id/export - Export workspace (ZIP/JSON)
* POST /api/v1/admin/workspaces/:id/import - Import into workspace
*
* Workflows:
* GET /api/v1/admin/workflows - List all workflows
* GET /api/v1/admin/workflows/:id - Get workflow details
* DELETE /api/v1/admin/workflows/:id - Delete workflow
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
* POST /api/v1/admin/workflows/import - Import single workflow
*
* Organizations:
* GET /api/v1/admin/organizations - List all organizations
* GET /api/v1/admin/organizations/:id - Get organization details
* PATCH /api/v1/admin/organizations/:id - Update organization
* GET /api/v1/admin/organizations/:id/members - List organization members
* POST /api/v1/admin/organizations/:id/members - Add member to organization
* GET /api/v1/admin/organizations/:id/members/:mid - Get member details
* PATCH /api/v1/admin/organizations/:id/members/:mid - Update member role
* DELETE /api/v1/admin/organizations/:id/members/:mid - Remove member
* GET /api/v1/admin/organizations/:id/billing - Get org billing summary
* PATCH /api/v1/admin/organizations/:id/billing - Update org usage limit
* GET /api/v1/admin/organizations/:id/seats - Get seat analytics
* PATCH /api/v1/admin/organizations/:id/seats - Update seat count
*
* Subscriptions:
* GET /api/v1/admin/subscriptions - List all subscriptions
* GET /api/v1/admin/subscriptions/:id - Get subscription details
* PATCH /api/v1/admin/subscriptions/:id - Update subscription
*/
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'
@@ -42,13 +70,26 @@ export type {
AdminErrorResponse,
AdminFolder,
AdminListResponse,
AdminMember,
AdminMemberDetail,
AdminOrganization,
AdminOrganizationBillingSummary,
AdminOrganizationDetail,
AdminSeatAnalytics,
AdminSingleResponse,
AdminSubscription,
AdminUser,
AdminUserBilling,
AdminUserBillingWithSubscription,
AdminWorkflow,
AdminWorkflowDetail,
AdminWorkspace,
AdminWorkspaceDetail,
DbMember,
DbOrganization,
DbSubscription,
DbUser,
DbUserStats,
DbWorkflow,
DbWorkflowFolder,
DbWorkspace,
@@ -73,6 +114,8 @@ export {
parsePaginationParams,
parseWorkflowVariables,
toAdminFolder,
toAdminOrganization,
toAdminSubscription,
toAdminUser,
toAdminWorkflow,
toAdminWorkspace,

View File

@@ -0,0 +1,135 @@
/**
* GET /api/v1/admin/organizations/[id]/billing
*
* Get organization billing summary including usage, seats, and member data.
*
* Response: AdminSingleResponse<AdminOrganizationBillingSummary>
*
* PATCH /api/v1/admin/organizations/[id]/billing
*
* Update organization billing settings.
*
* Body:
* - orgUsageLimit?: number - New usage limit (null to clear)
*
* Response: AdminSingleResponse<{ success: true, orgUsageLimit: string | null }>
*/
import { db } from '@sim/db'
import { organization } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminOrganizationBillingSummary } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationBillingAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
const { id: organizationId } = await context.params
try {
const billingData = await getOrganizationBillingData(organizationId)
if (!billingData) {
return notFoundResponse('Organization or subscription')
}
const membersOverLimit = billingData.members.filter((m) => m.isOverLimit).length
const membersNearLimit = billingData.members.filter(
(m) => !m.isOverLimit && m.percentUsed >= 80
).length
const usagePercentage =
billingData.totalUsageLimit > 0
? Math.round((billingData.totalCurrentUsage / billingData.totalUsageLimit) * 10000) / 100
: 0
const data: AdminOrganizationBillingSummary = {
organizationId: billingData.organizationId,
organizationName: billingData.organizationName,
subscriptionPlan: billingData.subscriptionPlan,
subscriptionStatus: billingData.subscriptionStatus,
totalSeats: billingData.totalSeats,
usedSeats: billingData.usedSeats,
availableSeats: billingData.totalSeats - billingData.usedSeats,
totalCurrentUsage: billingData.totalCurrentUsage,
totalUsageLimit: billingData.totalUsageLimit,
minimumBillingAmount: billingData.minimumBillingAmount,
averageUsagePerMember: billingData.averageUsagePerMember,
usagePercentage,
billingPeriodStart: billingData.billingPeriodStart?.toISOString() ?? null,
billingPeriodEnd: billingData.billingPeriodEnd?.toISOString() ?? null,
membersOverLimit,
membersNearLimit,
}
logger.info(`Admin API: Retrieved billing summary for organization ${organizationId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get organization billing', { error, organizationId })
return internalErrorResponse('Failed to get organization billing')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: organizationId } = await context.params
try {
const body = await request.json()
const [orgData] = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
if (body.orgUsageLimit !== undefined) {
let newLimit: string | null = null
if (body.orgUsageLimit === null) {
newLimit = null
} else if (typeof body.orgUsageLimit === 'number' && body.orgUsageLimit >= 0) {
newLimit = body.orgUsageLimit.toFixed(2)
} else {
return badRequestResponse('orgUsageLimit must be a non-negative number or null')
}
await db
.update(organization)
.set({
orgUsageLimit: newLimit,
updatedAt: new Date(),
})
.where(eq(organization.id, organizationId))
logger.info(`Admin API: Updated usage limit for organization ${organizationId}`, {
newLimit,
})
return singleResponse({
success: true,
orgUsageLimit: newLimit,
})
}
return badRequestResponse('No valid fields to update')
} catch (error) {
logger.error('Admin API: Failed to update organization billing', { error, organizationId })
return internalErrorResponse('Failed to update organization billing')
}
})

View File

@@ -0,0 +1,251 @@
/**
* GET /api/v1/admin/organizations/[id]/members/[memberId]
*
* Get member details.
*
* Response: AdminSingleResponse<AdminMemberDetail>
*
* PATCH /api/v1/admin/organizations/[id]/members/[memberId]
*
* Update member role.
*
* Body:
* - role: string - New role ('admin' | 'member')
*
* Response: AdminSingleResponse<AdminMember>
*
* DELETE /api/v1/admin/organizations/[id]/members/[memberId]
*
* Remove member from organization with full billing logic.
* Handles departed usage capture and Pro restoration like the regular flow.
*
* Query Parameters:
* - skipBillingLogic: boolean - Skip billing logic (default: false)
*
* Response: { success: true, memberId: string, billingActions: {...} }
*/
import { db } from '@sim/db'
import { member, organization, user, userStats } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminMember, AdminMemberDetail } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationMemberDetailAPI')
interface RouteParams {
id: string
memberId: string
}
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
const { id: organizationId, memberId } = await context.params
try {
const [orgData] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [memberData] = await db
.select({
id: member.id,
userId: member.userId,
organizationId: member.organizationId,
role: member.role,
createdAt: member.createdAt,
userName: user.name,
userEmail: user.email,
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
lastActive: userStats.lastActive,
billingBlocked: userStats.billingBlocked,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
.limit(1)
if (!memberData) {
return notFoundResponse('Member')
}
const data: AdminMemberDetail = {
id: memberData.id,
userId: memberData.userId,
organizationId: memberData.organizationId,
role: memberData.role,
createdAt: memberData.createdAt.toISOString(),
userName: memberData.userName,
userEmail: memberData.userEmail,
currentPeriodCost: memberData.currentPeriodCost ?? '0',
currentUsageLimit: memberData.currentUsageLimit,
lastActive: memberData.lastActive?.toISOString() ?? null,
billingBlocked: memberData.billingBlocked ?? false,
}
logger.info(`Admin API: Retrieved member ${memberId} from organization ${organizationId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get member', { error, organizationId, memberId })
return internalErrorResponse('Failed to get member')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: organizationId, memberId } = await context.params
try {
const body = await request.json()
if (!body.role || !['admin', 'member'].includes(body.role)) {
return badRequestResponse('role must be "admin" or "member"')
}
const [orgData] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [existingMember] = await db
.select({
id: member.id,
userId: member.userId,
role: member.role,
})
.from(member)
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
.limit(1)
if (!existingMember) {
return notFoundResponse('Member')
}
if (existingMember.role === 'owner') {
return badRequestResponse('Cannot change owner role')
}
const [updated] = await db
.update(member)
.set({ role: body.role })
.where(eq(member.id, memberId))
.returning()
const [userData] = await db
.select({ name: user.name, email: user.email })
.from(user)
.where(eq(user.id, updated.userId))
.limit(1)
const data: AdminMember = {
id: updated.id,
userId: updated.userId,
organizationId: updated.organizationId,
role: updated.role,
createdAt: updated.createdAt.toISOString(),
userName: userData?.name ?? '',
userEmail: userData?.email ?? '',
}
logger.info(`Admin API: Updated member ${memberId} role to ${body.role}`, {
organizationId,
previousRole: existingMember.role,
})
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to update member', { error, organizationId, memberId })
return internalErrorResponse('Failed to update member')
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: organizationId, memberId } = await context.params
const url = new URL(request.url)
const skipBillingLogic = url.searchParams.get('skipBillingLogic') === 'true'
try {
const [orgData] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [existingMember] = await db
.select({
id: member.id,
userId: member.userId,
role: member.role,
})
.from(member)
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
.limit(1)
if (!existingMember) {
return notFoundResponse('Member')
}
const userId = existingMember.userId
const result = await removeUserFromOrganization({
userId,
organizationId,
memberId,
skipBillingLogic,
})
if (!result.success) {
if (result.error === 'Cannot remove organization owner') {
return badRequestResponse(result.error)
}
if (result.error === 'Member not found') {
return notFoundResponse('Member')
}
return internalErrorResponse(result.error || 'Failed to remove member')
}
logger.info(`Admin API: Removed member ${memberId} from organization ${organizationId}`, {
userId,
billingActions: result.billingActions,
})
return singleResponse({
success: true,
memberId,
userId,
billingActions: {
usageCaptured: result.billingActions.usageCaptured,
proRestored: result.billingActions.proRestored,
usageRestored: result.billingActions.usageRestored,
skipBillingLogic,
},
})
} catch (error) {
logger.error('Admin API: Failed to remove member', { error, organizationId, memberId })
return internalErrorResponse('Failed to remove member')
}
})

View File

@@ -0,0 +1,193 @@
/**
* GET /api/v1/admin/organizations/[id]/members
*
* List all members of an organization with their billing info.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminMemberDetail>
*
* POST /api/v1/admin/organizations/[id]/members
*
* Add a user to an organization with full billing logic.
* Handles Pro usage snapshot and subscription cancellation like the invitation flow.
*
* Body:
* - userId: string - User ID to add
* - role: string - Role ('admin' | 'member')
* - skipBillingLogic?: boolean - Skip Pro cancellation (default: false)
*
* Response: AdminSingleResponse<AdminMember>
*/
import { db } from '@sim/db'
import { member, organization, user, userStats } from '@sim/db/schema'
import { count, eq } from 'drizzle-orm'
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminMember,
type AdminMemberDetail,
createPaginationMeta,
parsePaginationParams,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationMembersAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: organizationId } = await context.params
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
try {
const [orgData] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [countResult, membersData] = await Promise.all([
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
db
.select({
id: member.id,
userId: member.userId,
organizationId: member.organizationId,
role: member.role,
createdAt: member.createdAt,
userName: user.name,
userEmail: user.email,
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
lastActive: userStats.lastActive,
billingBlocked: userStats.billingBlocked,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
.orderBy(member.createdAt)
.limit(limit)
.offset(offset),
])
const total = countResult[0].count
const data: AdminMemberDetail[] = membersData.map((m) => ({
id: m.id,
userId: m.userId,
organizationId: m.organizationId,
role: m.role,
createdAt: m.createdAt.toISOString(),
userName: m.userName,
userEmail: m.userEmail,
currentPeriodCost: m.currentPeriodCost ?? '0',
currentUsageLimit: m.currentUsageLimit,
lastActive: m.lastActive?.toISOString() ?? null,
billingBlocked: m.billingBlocked ?? false,
}))
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} members for organization ${organizationId}`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list organization members', { error, organizationId })
return internalErrorResponse('Failed to list organization members')
}
})
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: organizationId } = await context.params
try {
const body = await request.json()
if (!body.userId || typeof body.userId !== 'string') {
return badRequestResponse('userId is required')
}
if (!body.role || !['admin', 'member'].includes(body.role)) {
return badRequestResponse('role must be "admin" or "member"')
}
const skipBillingLogic = body.skipBillingLogic === true
const [orgData] = await db
.select({ id: organization.id, name: organization.name })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [userData] = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, body.userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
const result = await addUserToOrganization({
userId: body.userId,
organizationId,
role: body.role,
skipBillingLogic,
})
if (!result.success) {
return badRequestResponse(result.error || 'Failed to add member')
}
const data: AdminMember = {
id: result.memberId!,
userId: body.userId,
organizationId,
role: body.role,
createdAt: new Date().toISOString(),
userName: userData.name,
userEmail: userData.email,
}
logger.info(`Admin API: Added user ${body.userId} to organization ${organizationId}`, {
role: body.role,
memberId: result.memberId,
billingActions: result.billingActions,
skipBillingLogic,
})
return singleResponse({
...data,
billingActions: {
proUsageSnapshotted: result.billingActions.proUsageSnapshotted,
proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd,
},
})
} catch (error) {
logger.error('Admin API: Failed to add organization member', { error, organizationId })
return internalErrorResponse('Failed to add organization member')
}
})

View File

@@ -0,0 +1,140 @@
/**
* GET /api/v1/admin/organizations/[id]
*
* Get organization details including member count and subscription.
*
* Response: AdminSingleResponse<AdminOrganizationDetail>
*
* PATCH /api/v1/admin/organizations/[id]
*
* Update organization details.
*
* Body:
* - name?: string - Organization name
* - slug?: string - Organization slug
* - orgUsageLimit?: number - Usage limit (null to clear)
*
* Response: AdminSingleResponse<AdminOrganization>
*/
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { and, count, eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminOrganizationDetail,
toAdminOrganization,
toAdminSubscription,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationDetailAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: organizationId } = await context.params
try {
const [orgData] = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [memberCountResult, subscriptionData] = await Promise.all([
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.limit(1),
])
const data: AdminOrganizationDetail = {
...toAdminOrganization(orgData),
memberCount: memberCountResult[0].count,
subscription: subscriptionData[0] ? toAdminSubscription(subscriptionData[0]) : null,
}
logger.info(`Admin API: Retrieved organization ${organizationId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get organization', { error, organizationId })
return internalErrorResponse('Failed to get organization')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: organizationId } = await context.params
try {
const body = await request.json()
const [existing] = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!existing) {
return notFoundResponse('Organization')
}
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
}
if (body.name !== undefined) {
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
return badRequestResponse('name must be a non-empty string')
}
updateData.name = body.name.trim()
}
if (body.slug !== undefined) {
if (typeof body.slug !== 'string' || body.slug.trim().length === 0) {
return badRequestResponse('slug must be a non-empty string')
}
updateData.slug = body.slug.trim()
}
if (body.orgUsageLimit !== undefined) {
if (body.orgUsageLimit === null) {
updateData.orgUsageLimit = null
} else if (typeof body.orgUsageLimit === 'number' && body.orgUsageLimit >= 0) {
updateData.orgUsageLimit = body.orgUsageLimit.toFixed(2)
} else {
return badRequestResponse('orgUsageLimit must be a non-negative number or null')
}
}
const [updated] = await db
.update(organization)
.set(updateData)
.where(eq(organization.id, organizationId))
.returning()
logger.info(`Admin API: Updated organization ${organizationId}`, {
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
})
return singleResponse(toAdminOrganization(updated))
} catch (error) {
logger.error('Admin API: Failed to update organization', { error, organizationId })
return internalErrorResponse('Failed to update organization')
}
})

View File

@@ -0,0 +1,143 @@
/**
* GET /api/v1/admin/organizations/[id]/seats
*
* Get organization seat analytics including member activity.
*
* Response: AdminSingleResponse<AdminSeatAnalytics>
*
* PATCH /api/v1/admin/organizations/[id]/seats
*
* Update organization seat count (for admin override of enterprise seats).
*
* Body:
* - seats: number - New seat count (for enterprise metadata.seats)
*
* Response: AdminSingleResponse<{ success: true, seats: number }>
*/
import { db } from '@sim/db'
import { organization, subscription } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { getOrganizationSeatAnalytics } from '@/lib/billing/validation/seat-management'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminSeatAnalytics } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationSeatsAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
const { id: organizationId } = await context.params
try {
const analytics = await getOrganizationSeatAnalytics(organizationId)
if (!analytics) {
return notFoundResponse('Organization or subscription')
}
const data: AdminSeatAnalytics = {
organizationId: analytics.organizationId,
organizationName: analytics.organizationName,
currentSeats: analytics.currentSeats,
maxSeats: analytics.maxSeats,
availableSeats: analytics.availableSeats,
subscriptionPlan: analytics.subscriptionPlan,
canAddSeats: analytics.canAddSeats,
utilizationRate: analytics.utilizationRate,
activeMembers: analytics.activeMembers,
inactiveMembers: analytics.inactiveMembers,
memberActivity: analytics.memberActivity.map((m) => ({
userId: m.userId,
userName: m.userName,
userEmail: m.userEmail,
role: m.role,
joinedAt: m.joinedAt.toISOString(),
lastActive: m.lastActive?.toISOString() ?? null,
})),
}
logger.info(`Admin API: Retrieved seat analytics for organization ${organizationId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get organization seats', { error, organizationId })
return internalErrorResponse('Failed to get organization seats')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: organizationId } = await context.params
try {
const body = await request.json()
if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) {
return badRequestResponse('seats must be a positive integer')
}
const [orgData] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [subData] = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.limit(1)
if (!subData) {
return notFoundResponse('Subscription')
}
if (subData.plan === 'enterprise') {
const currentMetadata = (subData.metadata as Record<string, unknown>) || {}
const newMetadata = {
...currentMetadata,
seats: body.seats,
}
await db
.update(subscription)
.set({ metadata: newMetadata })
.where(eq(subscription.id, subData.id))
logger.info(`Admin API: Updated enterprise seats for organization ${organizationId}`, {
seats: body.seats,
})
} else {
await db
.update(subscription)
.set({ seats: body.seats })
.where(eq(subscription.id, subData.id))
logger.info(`Admin API: Updated team seats for organization ${organizationId}`, {
seats: body.seats,
})
}
return singleResponse({
success: true,
seats: body.seats,
plan: subData.plan,
})
} catch (error) {
logger.error('Admin API: Failed to update organization seats', { error, organizationId })
return internalErrorResponse('Failed to update organization seats')
}
})

View File

@@ -0,0 +1,49 @@
/**
* GET /api/v1/admin/organizations
*
* List all organizations with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminOrganization>
*/
import { db } from '@sim/db'
import { organization } from '@sim/db/schema'
import { count } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
import {
type AdminOrganization,
createPaginationMeta,
parsePaginationParams,
toAdminOrganization,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationsAPI')
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
try {
const [countResult, organizations] = await Promise.all([
db.select({ total: count() }).from(organization),
db.select().from(organization).orderBy(organization.name).limit(limit).offset(offset),
])
const total = countResult[0].total
const data: AdminOrganization[] = organizations.map(toAdminOrganization)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} organizations (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list organizations', { error })
return internalErrorResponse('Failed to list organizations')
}
})

View File

@@ -0,0 +1,236 @@
/**
* GET /api/v1/admin/subscriptions/[id]
*
* Get subscription details.
*
* Response: AdminSingleResponse<AdminSubscription>
*
* PATCH /api/v1/admin/subscriptions/[id]
*
* Update subscription details with optional side effects.
*
* Body:
* - plan?: string - New plan (free, pro, team, enterprise)
* - status?: string - New status (active, canceled, etc.)
* - seats?: number - Seat count (for team plans)
* - metadata?: object - Subscription metadata (for enterprise)
* - periodStart?: string - Period start (ISO date)
* - periodEnd?: string - Period end (ISO date)
* - cancelAtPeriodEnd?: boolean - Cancel at period end flag
* - syncLimits?: boolean - Sync usage limits for affected users (default: false)
* - reason?: string - Reason for the change (for audit logging)
*
* Response: AdminSingleResponse<AdminSubscription & { sideEffects }>
*/
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { toAdminSubscription } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminSubscriptionDetailAPI')
interface RouteParams {
id: string
}
const VALID_PLANS = ['free', 'pro', 'team', 'enterprise']
const VALID_STATUSES = ['active', 'canceled', 'past_due', 'unpaid', 'trialing', 'incomplete']
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
const { id: subscriptionId } = await context.params
try {
const [subData] = await db
.select()
.from(subscription)
.where(eq(subscription.id, subscriptionId))
.limit(1)
if (!subData) {
return notFoundResponse('Subscription')
}
logger.info(`Admin API: Retrieved subscription ${subscriptionId}`)
return singleResponse(toAdminSubscription(subData))
} catch (error) {
logger.error('Admin API: Failed to get subscription', { error, subscriptionId })
return internalErrorResponse('Failed to get subscription')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: subscriptionId } = await context.params
try {
const body = await request.json()
const syncLimits = body.syncLimits === true
const reason = body.reason || 'Admin update (no reason provided)'
const [existing] = await db
.select()
.from(subscription)
.where(eq(subscription.id, subscriptionId))
.limit(1)
if (!existing) {
return notFoundResponse('Subscription')
}
const updateData: Record<string, unknown> = {}
const warnings: string[] = []
if (body.plan !== undefined) {
if (!VALID_PLANS.includes(body.plan)) {
return badRequestResponse(`plan must be one of: ${VALID_PLANS.join(', ')}`)
}
if (body.plan !== existing.plan) {
warnings.push(
`Plan change from ${existing.plan} to ${body.plan}. This does NOT update Stripe - manual sync required.`
)
}
updateData.plan = body.plan
}
if (body.status !== undefined) {
if (!VALID_STATUSES.includes(body.status)) {
return badRequestResponse(`status must be one of: ${VALID_STATUSES.join(', ')}`)
}
if (body.status !== existing.status) {
warnings.push(
`Status change from ${existing.status} to ${body.status}. This does NOT update Stripe - manual sync required.`
)
}
updateData.status = body.status
}
if (body.seats !== undefined) {
if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) {
return badRequestResponse('seats must be a positive integer')
}
updateData.seats = body.seats
}
if (body.metadata !== undefined) {
if (typeof body.metadata !== 'object' || body.metadata === null) {
return badRequestResponse('metadata must be an object')
}
updateData.metadata = {
...((existing.metadata as Record<string, unknown>) || {}),
...body.metadata,
}
}
if (body.periodStart !== undefined) {
const date = new Date(body.periodStart)
if (Number.isNaN(date.getTime())) {
return badRequestResponse('periodStart must be a valid ISO date')
}
updateData.periodStart = date
}
if (body.periodEnd !== undefined) {
const date = new Date(body.periodEnd)
if (Number.isNaN(date.getTime())) {
return badRequestResponse('periodEnd must be a valid ISO date')
}
updateData.periodEnd = date
}
if (body.cancelAtPeriodEnd !== undefined) {
if (typeof body.cancelAtPeriodEnd !== 'boolean') {
return badRequestResponse('cancelAtPeriodEnd must be a boolean')
}
updateData.cancelAtPeriodEnd = body.cancelAtPeriodEnd
}
if (Object.keys(updateData).length === 0) {
return badRequestResponse('No valid fields to update')
}
const [updated] = await db
.update(subscription)
.set(updateData)
.where(eq(subscription.id, subscriptionId))
.returning()
const sideEffects: {
limitsSynced: boolean
usersAffected: string[]
errors: string[]
} = {
limitsSynced: false,
usersAffected: [],
errors: [],
}
if (syncLimits) {
try {
const referenceId = updated.referenceId
if (['free', 'pro'].includes(updated.plan)) {
await syncUsageLimitsFromSubscription(referenceId)
sideEffects.usersAffected.push(referenceId)
sideEffects.limitsSynced = true
} else if (['team', 'enterprise'].includes(updated.plan)) {
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, referenceId))
for (const m of members) {
try {
await syncUsageLimitsFromSubscription(m.userId)
sideEffects.usersAffected.push(m.userId)
} catch (memberError) {
sideEffects.errors.push(`Failed to sync limits for user ${m.userId}`)
logger.error('Admin API: Failed to sync limits for member', {
userId: m.userId,
error: memberError,
})
}
}
sideEffects.limitsSynced = members.length > 0
}
logger.info('Admin API: Synced usage limits after subscription update', {
subscriptionId,
usersAffected: sideEffects.usersAffected.length,
})
} catch (syncError) {
sideEffects.errors.push('Failed to sync usage limits')
logger.error('Admin API: Failed to sync usage limits', {
subscriptionId,
error: syncError,
})
}
}
logger.info(`Admin API: Updated subscription ${subscriptionId}`, {
fields: Object.keys(updateData),
previousPlan: existing.plan,
previousStatus: existing.status,
syncLimits,
reason,
})
return singleResponse({
...toAdminSubscription(updated),
sideEffects,
warnings,
})
} catch (error) {
logger.error('Admin API: Failed to update subscription', { error, subscriptionId })
return internalErrorResponse('Failed to update subscription')
}
})

View File

@@ -0,0 +1,69 @@
/**
* GET /api/v1/admin/subscriptions
*
* List all subscriptions with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
* - plan: string (optional) - Filter by plan (free, pro, team, enterprise)
* - status: string (optional) - Filter by status (active, canceled, etc.)
*
* Response: AdminListResponse<AdminSubscription>
*/
import { db } from '@sim/db'
import { subscription } from '@sim/db/schema'
import { and, count, eq, type SQL } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
import {
type AdminSubscription,
createPaginationMeta,
parsePaginationParams,
toAdminSubscription,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminSubscriptionsAPI')
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
const planFilter = url.searchParams.get('plan')
const statusFilter = url.searchParams.get('status')
try {
const conditions: SQL<unknown>[] = []
if (planFilter) {
conditions.push(eq(subscription.plan, planFilter))
}
if (statusFilter) {
conditions.push(eq(subscription.status, statusFilter))
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const [countResult, subscriptions] = await Promise.all([
db.select({ total: count() }).from(subscription).where(whereClause),
db
.select()
.from(subscription)
.where(whereClause)
.orderBy(subscription.plan)
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminSubscription[] = subscriptions.map(toAdminSubscription)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} subscriptions (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list subscriptions', { error })
return internalErrorResponse('Failed to list subscriptions')
}
})

View File

@@ -5,7 +5,16 @@
* All responses follow a consistent structure for predictability.
*/
import type { user, workflow, workflowFolder, workspace } from '@sim/db/schema'
import type {
member,
organization,
subscription,
user,
userStats,
workflow,
workflowFolder,
workspace,
} from '@sim/db/schema'
import type { InferSelectModel } from 'drizzle-orm'
import type { Edge } from 'reactflow'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
@@ -18,6 +27,10 @@ export type DbUser = InferSelectModel<typeof user>
export type DbWorkspace = InferSelectModel<typeof workspace>
export type DbWorkflow = InferSelectModel<typeof workflow>
export type DbWorkflowFolder = InferSelectModel<typeof workflowFolder>
export type DbOrganization = InferSelectModel<typeof organization>
export type DbSubscription = InferSelectModel<typeof subscription>
export type DbMember = InferSelectModel<typeof member>
export type DbUserStats = InferSelectModel<typeof userStats>
// =============================================================================
// Pagination
@@ -400,3 +413,189 @@ function getNestedString(obj: Record<string, unknown>, path: string): string | u
return typeof current === 'string' ? current : undefined
}
// =============================================================================
// Organization Types
// =============================================================================
export interface AdminOrganization {
id: string
name: string
slug: string
logo: string | null
orgUsageLimit: string | null
storageUsedBytes: number
departedMemberUsage: string
createdAt: string
updatedAt: string
}
export interface AdminOrganizationDetail extends AdminOrganization {
memberCount: number
subscription: AdminSubscription | null
}
export function toAdminOrganization(dbOrg: DbOrganization): AdminOrganization {
return {
id: dbOrg.id,
name: dbOrg.name,
slug: dbOrg.slug,
logo: dbOrg.logo,
orgUsageLimit: dbOrg.orgUsageLimit,
storageUsedBytes: dbOrg.storageUsedBytes,
departedMemberUsage: dbOrg.departedMemberUsage,
createdAt: dbOrg.createdAt.toISOString(),
updatedAt: dbOrg.updatedAt.toISOString(),
}
}
// =============================================================================
// Subscription Types
// =============================================================================
export interface AdminSubscription {
id: string
plan: string
referenceId: string
stripeCustomerId: string | null
stripeSubscriptionId: string | null
status: string | null
periodStart: string | null
periodEnd: string | null
cancelAtPeriodEnd: boolean | null
seats: number | null
trialStart: string | null
trialEnd: string | null
metadata: unknown
}
export function toAdminSubscription(dbSub: DbSubscription): AdminSubscription {
return {
id: dbSub.id,
plan: dbSub.plan,
referenceId: dbSub.referenceId,
stripeCustomerId: dbSub.stripeCustomerId,
stripeSubscriptionId: dbSub.stripeSubscriptionId,
status: dbSub.status,
periodStart: dbSub.periodStart?.toISOString() ?? null,
periodEnd: dbSub.periodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: dbSub.cancelAtPeriodEnd,
seats: dbSub.seats,
trialStart: dbSub.trialStart?.toISOString() ?? null,
trialEnd: dbSub.trialEnd?.toISOString() ?? null,
metadata: dbSub.metadata,
}
}
// =============================================================================
// Member Types
// =============================================================================
export interface AdminMember {
id: string
userId: string
organizationId: string
role: string
createdAt: string
// Joined user info
userName: string
userEmail: string
}
export interface AdminMemberDetail extends AdminMember {
// Billing/usage info from userStats
currentPeriodCost: string
currentUsageLimit: string | null
lastActive: string | null
billingBlocked: boolean
}
// =============================================================================
// User Billing Types
// =============================================================================
export interface AdminUserBilling {
userId: string
// User info
userName: string
userEmail: string
stripeCustomerId: string | null
// Usage stats
totalManualExecutions: number
totalApiCalls: number
totalWebhookTriggers: number
totalScheduledExecutions: number
totalChatExecutions: number
totalTokensUsed: number
totalCost: string
currentUsageLimit: string | null
currentPeriodCost: string
lastPeriodCost: string | null
billedOverageThisPeriod: string
storageUsedBytes: number
lastActive: string | null
billingBlocked: boolean
// Copilot usage
totalCopilotCost: string
currentPeriodCopilotCost: string
lastPeriodCopilotCost: string | null
totalCopilotTokens: number
totalCopilotCalls: number
}
export interface AdminUserBillingWithSubscription extends AdminUserBilling {
subscriptions: AdminSubscription[]
organizationMemberships: Array<{
organizationId: string
organizationName: string
role: string
}>
}
// =============================================================================
// Organization Billing Summary Types
// =============================================================================
export interface AdminOrganizationBillingSummary {
organizationId: string
organizationName: string
subscriptionPlan: string
subscriptionStatus: string
// Seats
totalSeats: number
usedSeats: number
availableSeats: number
// Usage
totalCurrentUsage: number
totalUsageLimit: number
minimumBillingAmount: number
averageUsagePerMember: number
usagePercentage: number
// Billing period
billingPeriodStart: string | null
billingPeriodEnd: string | null
// Alerts
membersOverLimit: number
membersNearLimit: number
}
export interface AdminSeatAnalytics {
organizationId: string
organizationName: string
currentSeats: number
maxSeats: number
availableSeats: number
subscriptionPlan: string
canAddSeats: boolean
utilizationRate: number
activeMembers: number
inactiveMembers: number
memberActivity: Array<{
userId: string
userName: string
userEmail: string
role: string
joinedAt: string
lastActive: string | null
}>
}

View File

@@ -0,0 +1,160 @@
/**
* POST /api/v1/admin/users/[id]/billing/move-to-org
*
* Move a user to an organization with full billing logic.
* Enforces single-org constraint, handles Pro snapshot/cancellation.
*
* Body:
* - organizationId: string - Target organization ID
* - role?: string - Role in organization ('admin' | 'member'), defaults to 'member'
* - skipBillingLogic?: boolean - Skip Pro handling (default: false)
*
* Response: AdminSingleResponse<{
* success: true,
* memberId: string,
* organizationId: string,
* role: string,
* action: 'created' | 'updated' | 'already_member',
* billingActions: { proUsageSnapshotted, proCancelledAtPeriodEnd }
* }>
*/
import { db } from '@sim/db'
import { member, organization, user } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminUserMoveToOrgAPI')
interface RouteParams {
id: string
}
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: userId } = await context.params
try {
const body = await request.json()
if (!body.organizationId || typeof body.organizationId !== 'string') {
return badRequestResponse('organizationId is required')
}
const role = body.role || 'member'
if (!['admin', 'member'].includes(role)) {
return badRequestResponse('role must be "admin" or "member"')
}
const skipBillingLogic = body.skipBillingLogic === true
const [userData] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
const [orgData] = await db
.select({ id: organization.id, name: organization.name })
.from(organization)
.where(eq(organization.id, body.organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const existingMemberships = await db
.select({ id: member.id, organizationId: member.organizationId, role: member.role })
.from(member)
.where(eq(member.userId, userId))
const existingInThisOrg = existingMemberships.find(
(m) => m.organizationId === body.organizationId
)
if (existingInThisOrg) {
if (existingInThisOrg.role !== role) {
await db.update(member).set({ role }).where(eq(member.id, existingInThisOrg.id))
logger.info(
`Admin API: Updated user ${userId} role in organization ${body.organizationId}`,
{
previousRole: existingInThisOrg.role,
newRole: role,
}
)
return singleResponse({
success: true,
memberId: existingInThisOrg.id,
organizationId: body.organizationId,
organizationName: orgData.name,
role,
action: 'updated',
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
}
return singleResponse({
success: true,
memberId: existingInThisOrg.id,
organizationId: body.organizationId,
organizationName: orgData.name,
role: existingInThisOrg.role,
action: 'already_member',
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
}
const result = await addUserToOrganization({
userId,
organizationId: body.organizationId,
role,
skipBillingLogic,
})
if (!result.success) {
return badRequestResponse(result.error || 'Failed to move user to organization')
}
logger.info(`Admin API: Moved user ${userId} to organization ${body.organizationId}`, {
role,
memberId: result.memberId,
billingActions: result.billingActions,
skipBillingLogic,
})
return singleResponse({
success: true,
memberId: result.memberId,
organizationId: body.organizationId,
organizationName: orgData.name,
role,
action: 'created',
billingActions: {
proUsageSnapshotted: result.billingActions.proUsageSnapshotted,
proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd,
},
})
} catch (error) {
logger.error('Admin API: Failed to move user to organization', { error, userId })
return internalErrorResponse('Failed to move user to organization')
}
})

View File

@@ -0,0 +1,260 @@
/**
* GET /api/v1/admin/users/[id]/billing
*
* Get user billing information including usage stats, subscriptions, and org memberships.
*
* Response: AdminSingleResponse<AdminUserBillingWithSubscription>
*
* PATCH /api/v1/admin/users/[id]/billing
*
* Update user billing settings with proper validation.
*
* Body:
* - currentUsageLimit?: number | null - Usage limit (null to use default)
* - billingBlocked?: boolean - Block/unblock billing
* - currentPeriodCost?: number - Reset/adjust current period cost (use with caution)
* - reason?: string - Reason for the change (for audit logging)
*
* Response: AdminSingleResponse<{ success: true, updated: string[], warnings: string[] }>
*/
import { db } from '@sim/db'
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
import { eq, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminUserBillingWithSubscription,
toAdminSubscription,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminUserBillingAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
const { id: userId } = await context.params
try {
const [userData] = await db
.select({
id: user.id,
name: user.name,
email: user.email,
stripeCustomerId: user.stripeCustomerId,
})
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
const [stats] = await db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1)
const memberOrgs = await db
.select({
organizationId: member.organizationId,
organizationName: organization.name,
role: member.role,
})
.from(member)
.innerJoin(organization, eq(member.organizationId, organization.id))
.where(eq(member.userId, userId))
const orgIds = memberOrgs.map((m) => m.organizationId)
const subscriptions = await db
.select()
.from(subscription)
.where(
orgIds.length > 0
? or(
eq(subscription.referenceId, userId),
...orgIds.map((orgId) => eq(subscription.referenceId, orgId))
)
: eq(subscription.referenceId, userId)
)
const data: AdminUserBillingWithSubscription = {
userId: userData.id,
userName: userData.name,
userEmail: userData.email,
stripeCustomerId: userData.stripeCustomerId,
totalManualExecutions: stats?.totalManualExecutions ?? 0,
totalApiCalls: stats?.totalApiCalls ?? 0,
totalWebhookTriggers: stats?.totalWebhookTriggers ?? 0,
totalScheduledExecutions: stats?.totalScheduledExecutions ?? 0,
totalChatExecutions: stats?.totalChatExecutions ?? 0,
totalTokensUsed: stats?.totalTokensUsed ?? 0,
totalCost: stats?.totalCost ?? '0',
currentUsageLimit: stats?.currentUsageLimit ?? null,
currentPeriodCost: stats?.currentPeriodCost ?? '0',
lastPeriodCost: stats?.lastPeriodCost ?? null,
billedOverageThisPeriod: stats?.billedOverageThisPeriod ?? '0',
storageUsedBytes: stats?.storageUsedBytes ?? 0,
lastActive: stats?.lastActive?.toISOString() ?? null,
billingBlocked: stats?.billingBlocked ?? false,
totalCopilotCost: stats?.totalCopilotCost ?? '0',
currentPeriodCopilotCost: stats?.currentPeriodCopilotCost ?? '0',
lastPeriodCopilotCost: stats?.lastPeriodCopilotCost ?? null,
totalCopilotTokens: stats?.totalCopilotTokens ?? 0,
totalCopilotCalls: stats?.totalCopilotCalls ?? 0,
subscriptions: subscriptions.map(toAdminSubscription),
organizationMemberships: memberOrgs.map((m) => ({
organizationId: m.organizationId,
organizationName: m.organizationName,
role: m.role,
})),
}
logger.info(`Admin API: Retrieved billing for user ${userId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get user billing', { error, userId })
return internalErrorResponse('Failed to get user billing')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: userId } = await context.params
try {
const body = await request.json()
const reason = body.reason || 'Admin update (no reason provided)'
const [userData] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
const [existingStats] = await db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
const userSubscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterpriseMember =
userSubscription && ['team', 'enterprise'].includes(userSubscription.plan)
const [orgMembership] = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
.limit(1)
const updateData: Record<string, unknown> = {}
const updated: string[] = []
const warnings: string[] = []
if (body.currentUsageLimit !== undefined) {
if (isTeamOrEnterpriseMember && orgMembership) {
warnings.push(
'User is a team/enterprise member. Individual limits may be ignored in favor of organization limits.'
)
}
if (body.currentUsageLimit === null) {
updateData.currentUsageLimit = null
} else if (typeof body.currentUsageLimit === 'number' && body.currentUsageLimit >= 0) {
const currentCost = Number.parseFloat(existingStats?.currentPeriodCost || '0')
if (body.currentUsageLimit < currentCost) {
warnings.push(
`New limit ($${body.currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.`
)
}
updateData.currentUsageLimit = body.currentUsageLimit.toFixed(2)
} else {
return badRequestResponse('currentUsageLimit must be a non-negative number or null')
}
updateData.usageLimitUpdatedAt = new Date()
updated.push('currentUsageLimit')
}
if (body.billingBlocked !== undefined) {
if (typeof body.billingBlocked !== 'boolean') {
return badRequestResponse('billingBlocked must be a boolean')
}
if (body.billingBlocked === false && existingStats?.billingBlocked === true) {
warnings.push(
'Unblocking user. Ensure payment issues are resolved to prevent re-blocking on next invoice.'
)
}
updateData.billingBlocked = body.billingBlocked
updated.push('billingBlocked')
}
if (body.currentPeriodCost !== undefined) {
if (typeof body.currentPeriodCost !== 'number' || body.currentPeriodCost < 0) {
return badRequestResponse('currentPeriodCost must be a non-negative number')
}
const previousCost = existingStats?.currentPeriodCost || '0'
warnings.push(
`Manually adjusting currentPeriodCost from $${previousCost} to $${body.currentPeriodCost.toFixed(2)}. This may affect billing accuracy.`
)
updateData.currentPeriodCost = body.currentPeriodCost.toFixed(2)
updated.push('currentPeriodCost')
}
if (updated.length === 0) {
return badRequestResponse('No valid fields to update')
}
if (existingStats) {
await db.update(userStats).set(updateData).where(eq(userStats.userId, userId))
} else {
await db.insert(userStats).values({
id: nanoid(),
userId,
...updateData,
})
}
logger.info(`Admin API: Updated billing for user ${userId}`, {
updated,
warnings,
reason,
previousValues: existingStats
? {
currentUsageLimit: existingStats.currentUsageLimit,
billingBlocked: existingStats.billingBlocked,
currentPeriodCost: existingStats.currentPeriodCost,
}
: null,
newValues: updateData,
isTeamMember: !!orgMembership,
})
return singleResponse({
success: true,
updated,
warnings,
reason,
})
} catch (error) {
logger.error('Admin API: Failed to update user billing', { error, userId })
return internalErrorResponse('Failed to update user billing')
}
})

View File

@@ -84,7 +84,6 @@ export async function GET(request: NextRequest) {
},
})
// Build filter conditions
const filters = {
workspaceId: params.workspaceId,
workflowIds: params.workflowIds?.split(',').filter(Boolean),

View File

@@ -0,0 +1,588 @@
/**
* Organization Membership Management
*
* Shared helpers for adding and removing users from organizations.
* Used by both regular routes and admin routes to ensure consistent business logic.
*/
import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import {
member,
organization,
subscription as subscriptionTable,
user,
userStats,
} from '@sim/db/schema'
import { and, eq, sql } from 'drizzle-orm'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OrganizationMembership')
export interface AddMemberParams {
userId: string
organizationId: string
role: 'admin' | 'member' | 'owner'
/** Skip Pro snapshot/cancellation logic (default: false) */
skipBillingLogic?: boolean
/** Skip seat validation (default: false) */
skipSeatValidation?: boolean
}
export interface AddMemberResult {
success: boolean
memberId?: string
error?: string
billingActions: {
proUsageSnapshotted: boolean
proCancelledAtPeriodEnd: boolean
/** If Pro was cancelled, contains info for Stripe update (caller can optionally call Stripe) */
proSubscriptionToCancel?: {
subscriptionId: string
stripeSubscriptionId: string | null
}
}
}
export interface RemoveMemberParams {
userId: string
organizationId: string
memberId: string
/** Skip departed usage capture and Pro restoration (default: false) */
skipBillingLogic?: boolean
}
export interface RemoveMemberResult {
success: boolean
error?: string
billingActions: {
usageCaptured: number
proRestored: boolean
usageRestored: boolean
}
}
export interface MembershipValidationResult {
canAdd: boolean
reason?: string
existingOrgId?: string
seatValidation?: {
currentSeats: number
maxSeats: number
availableSeats: number
}
}
/**
* Validate if a user can be added to an organization.
* Checks single-org constraint and seat availability.
*/
export async function validateMembershipAddition(
userId: string,
organizationId: string
): Promise<MembershipValidationResult> {
const [userData] = await db.select({ id: user.id }).from(user).where(eq(user.id, userId)).limit(1)
if (!userData) {
return { canAdd: false, reason: 'User not found' }
}
const [orgData] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return { canAdd: false, reason: 'Organization not found' }
}
const existingMemberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
if (existingMemberships.length > 0) {
const isAlreadyMemberOfThisOrg = existingMemberships.some(
(m) => m.organizationId === organizationId
)
if (isAlreadyMemberOfThisOrg) {
return { canAdd: false, reason: 'User is already a member of this organization' }
}
return {
canAdd: false,
reason:
'User is already a member of another organization. Users can only belong to one organization at a time.',
existingOrgId: existingMemberships[0].organizationId,
}
}
const seatValidation = await validateSeatAvailability(organizationId, 1)
if (!seatValidation.canInvite) {
return {
canAdd: false,
reason: seatValidation.reason || 'No seats available',
seatValidation: {
currentSeats: seatValidation.currentSeats,
maxSeats: seatValidation.maxSeats,
availableSeats: seatValidation.availableSeats,
},
}
}
return {
canAdd: true,
seatValidation: {
currentSeats: seatValidation.currentSeats,
maxSeats: seatValidation.maxSeats,
availableSeats: seatValidation.availableSeats,
},
}
}
/**
* Add a user to an organization with full billing logic.
*
* Handles:
* - Single organization constraint validation
* - Seat availability validation
* - Member record creation
* - Pro usage snapshot when joining paid team
* - Pro subscription cancellation at period end
* - Usage limit sync
*/
export async function addUserToOrganization(params: AddMemberParams): Promise<AddMemberResult> {
const {
userId,
organizationId,
role,
skipBillingLogic = false,
skipSeatValidation = false,
} = params
const billingActions: AddMemberResult['billingActions'] = {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
}
try {
if (!skipSeatValidation) {
const validation = await validateMembershipAddition(userId, organizationId)
if (!validation.canAdd) {
return { success: false, error: validation.reason, billingActions }
}
} else {
const existingMemberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
if (existingMemberships.length > 0) {
const isAlreadyMemberOfThisOrg = existingMemberships.some(
(m) => m.organizationId === organizationId
)
if (isAlreadyMemberOfThisOrg) {
return {
success: false,
error: 'User is already a member of this organization',
billingActions,
}
}
return {
success: false,
error:
'User is already a member of another organization. Users can only belong to one organization at a time.',
billingActions,
}
}
}
const [orgSub] = await db
.select()
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, organizationId),
eq(subscriptionTable.status, 'active')
)
)
.limit(1)
const orgIsPaid = orgSub && (orgSub.plan === 'team' || orgSub.plan === 'enterprise')
let memberId = ''
await db.transaction(async (tx) => {
memberId = randomUUID()
await tx.insert(member).values({
id: memberId,
userId,
organizationId,
role,
createdAt: new Date(),
})
// Handle Pro subscription if org is paid and we're not skipping billing logic
if (orgIsPaid && !skipBillingLogic) {
// Find user's active personal Pro subscription
const [personalPro] = await tx
.select()
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, userId),
eq(subscriptionTable.status, 'active'),
eq(subscriptionTable.plan, 'pro')
)
)
.limit(1)
if (personalPro) {
// Snapshot the current Pro usage before resetting
const [userStatsRow] = await tx
.select({ currentPeriodCost: userStats.currentPeriodCost })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (userStatsRow) {
const currentProUsage = userStatsRow.currentPeriodCost || '0'
// Snapshot Pro usage and reset currentPeriodCost so new usage goes to team
await tx
.update(userStats)
.set({
proPeriodCostSnapshot: currentProUsage,
currentPeriodCost: '0',
currentPeriodCopilotCost: '0',
})
.where(eq(userStats.userId, userId))
billingActions.proUsageSnapshotted = true
logger.info('Snapshotted Pro usage when adding to team', {
userId,
proUsageSnapshot: currentProUsage,
organizationId,
})
}
// Mark Pro for cancellation at period end
if (!personalPro.cancelAtPeriodEnd) {
await tx
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscriptionTable.id, personalPro.id))
billingActions.proCancelledAtPeriodEnd = true
billingActions.proSubscriptionToCancel = {
subscriptionId: personalPro.id,
stripeSubscriptionId: personalPro.stripeSubscriptionId,
}
logger.info('Marked personal Pro for cancellation at period end', {
userId,
subscriptionId: personalPro.id,
organizationId,
})
}
}
}
})
logger.info('Added user to organization', {
userId,
organizationId,
role,
memberId,
billingActions,
})
return { success: true, memberId, billingActions }
} catch (error) {
logger.error('Failed to add user to organization', { userId, organizationId, error })
return { success: false, error: 'Failed to add user to organization', billingActions }
}
}
/**
* Remove a user from an organization with full billing logic.
*
* Handles:
* - Owner removal prevention
* - Departed member usage capture
* - Member record deletion
* - Pro subscription restoration when leaving a paid team
* - Pro usage restoration from snapshot
*
* Note: Users can only belong to one organization at a time.
*/
export async function removeUserFromOrganization(
params: RemoveMemberParams
): Promise<RemoveMemberResult> {
const { userId, organizationId, memberId, skipBillingLogic = false } = params
const billingActions = {
usageCaptured: 0,
proRestored: false,
usageRestored: false,
}
try {
// Check member exists and get their details
const [existingMember] = await db
.select({
id: member.id,
userId: member.userId,
role: member.role,
})
.from(member)
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
.limit(1)
if (!existingMember) {
return { success: false, error: 'Member not found', billingActions }
}
// Prevent removing owner
if (existingMember.role === 'owner') {
return { success: false, error: 'Cannot remove organization owner', billingActions }
}
// STEP 1: Capture departed member's usage (add to org's departedMemberUsage)
if (!skipBillingLogic) {
try {
const [departingUserStats] = await db
.select({ currentPeriodCost: userStats.currentPeriodCost })
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (departingUserStats?.currentPeriodCost) {
const usage = Number.parseFloat(departingUserStats.currentPeriodCost)
if (usage > 0) {
await db
.update(organization)
.set({
departedMemberUsage: sql`${organization.departedMemberUsage} + ${usage}`,
})
.where(eq(organization.id, organizationId))
await db
.update(userStats)
.set({ currentPeriodCost: '0' })
.where(eq(userStats.userId, userId))
billingActions.usageCaptured = usage
logger.info('Captured departed member usage', {
organizationId,
userId,
usage,
})
}
}
} catch (usageCaptureError) {
logger.error('Failed to capture departed member usage', {
organizationId,
userId,
error: usageCaptureError,
})
}
}
// STEP 2: Delete the member record
await db.delete(member).where(eq(member.id, memberId))
logger.info('Removed member from organization', {
organizationId,
userId,
memberId,
})
// STEP 3: Restore personal Pro if user has no remaining paid team memberships
if (!skipBillingLogic) {
try {
// Check for remaining paid team memberships
const remainingPaidTeams = await db
.select({ orgId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
let hasAnyPaidTeam = false
if (remainingPaidTeams.length > 0) {
const orgIds = remainingPaidTeams.map((m) => m.orgId)
const orgPaidSubs = await db
.select()
.from(subscriptionTable)
.where(eq(subscriptionTable.status, 'active'))
hasAnyPaidTeam = orgPaidSubs.some(
(s) => orgIds.includes(s.referenceId) && ['team', 'enterprise'].includes(s.plan ?? '')
)
}
// If no remaining paid teams, try to restore personal Pro
if (!hasAnyPaidTeam) {
const [personalPro] = await db
.select()
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, userId),
eq(subscriptionTable.status, 'active'),
eq(subscriptionTable.plan, 'pro')
)
)
.limit(1)
// Only restore if cancelAtPeriodEnd is true AND stripeSubscriptionId exists
if (
personalPro &&
personalPro.cancelAtPeriodEnd === true &&
personalPro.stripeSubscriptionId
) {
// Call Stripe API first (separate try/catch so failure doesn't prevent DB update)
try {
const stripe = requireStripeClient()
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
cancel_at_period_end: false,
})
} catch (stripeError) {
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
userId,
stripeSubscriptionId: personalPro.stripeSubscriptionId,
error: stripeError,
})
}
// Update DB (separate try/catch)
try {
await db
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: false })
.where(eq(subscriptionTable.id, personalPro.id))
billingActions.proRestored = true
logger.info('Restored personal Pro after leaving last paid team', {
userId,
personalSubscriptionId: personalPro.id,
})
} catch (dbError) {
logger.error('DB update failed when restoring personal Pro', {
userId,
subscriptionId: personalPro.id,
error: dbError,
})
}
// Restore snapshotted Pro usage (separate try/catch)
try {
const [stats] = await db
.select({
currentPeriodCost: userStats.currentPeriodCost,
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (stats) {
const currentUsage = stats.currentPeriodCost || '0'
const snapshotUsage = stats.proPeriodCostSnapshot || '0'
const currentNum = Number.parseFloat(currentUsage)
const snapshotNum = Number.parseFloat(snapshotUsage)
const restoredUsage = (currentNum + snapshotNum).toString()
await db
.update(userStats)
.set({
currentPeriodCost: restoredUsage,
proPeriodCostSnapshot: '0',
})
.where(eq(userStats.userId, userId))
billingActions.usageRestored = true
logger.info('Restored Pro usage after leaving team', {
userId,
previousUsage: currentUsage,
snapshotUsage: snapshotUsage,
restoredUsage: restoredUsage,
})
}
} catch (usageRestoreError) {
logger.error('Failed to restore Pro usage after leaving team', {
userId,
error: usageRestoreError,
})
}
}
}
} catch (postRemoveError) {
logger.error('Post-removal personal Pro restore check failed', {
organizationId,
userId,
error: postRemoveError,
})
}
}
return { success: true, billingActions }
} catch (error) {
logger.error('Failed to remove user from organization', {
userId,
organizationId,
memberId,
error,
})
return { success: false, error: 'Failed to remove user from organization', billingActions }
}
}
/**
* Check if a user is a member of a specific organization.
*/
export async function isUserMemberOfOrganization(
userId: string,
organizationId: string
): Promise<{ isMember: boolean; role?: string; memberId?: string }> {
const [memberRecord] = await db
.select({ id: member.id, role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
.limit(1)
if (memberRecord) {
return { isMember: true, role: memberRecord.role, memberId: memberRecord.id }
}
return { isMember: false }
}
/**
* Get user's current organization membership (if any).
*/
export async function getUserOrganization(
userId: string
): Promise<{ organizationId: string; role: string; memberId: string } | null> {
const [memberRecord] = await db
.select({
organizationId: member.organizationId,
role: member.role,
memberId: member.id,
})
.from(member)
.where(eq(member.userId, userId))
.limit(1)
return memberRecord || null
}