Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions apps/sim/app/api/v1/admin/credits/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* POST /api/v1/admin/credits
*
* Issue credits to a user by user ID or email.
*
* Body:
* - userId?: string - The user ID to issue credits to
* - email?: string - The user email to issue credits to (alternative to userId)
* - amount: number - The amount of credits to issue (in dollars)
* - reason?: string - Reason for issuing credits (for audit logging)
*
* Response: AdminSingleResponse<{
* success: true,
* entityType: 'user' | 'organization',
* entityId: string,
* amount: number,
* newCreditBalance: number,
* newUsageLimit: number,
* }>
*
* For Pro users: credits are added to user_stats.credit_balance
* For Team users: credits are added to organization.credit_balance
* Usage limits are updated accordingly to allow spending the credits.
*/

import { db } from '@sim/db'
import { organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { addCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'

const logger = createLogger('AdminCreditsAPI')

export const POST = withAdminAuth(async (request) => {
try {
const body = await request.json()
const { userId, email, amount, reason } = body

if (!userId && !email) {
return badRequestResponse('Either userId or email is required')
}

if (userId && typeof userId !== 'string') {
return badRequestResponse('userId must be a string')
}

if (email && typeof email !== 'string') {
return badRequestResponse('email must be a string')
}

if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
return badRequestResponse('amount must be a positive number')
}

let resolvedUserId: string
let userEmail: string | null = null

if (userId) {
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)

if (!userData) {
return notFoundResponse('User')
}
resolvedUserId = userData.id
userEmail = userData.email
} else {
const normalizedEmail = email.toLowerCase().trim()
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.email, normalizedEmail))
.limit(1)

if (!userData) {
return notFoundResponse('User with email')
}
resolvedUserId = userData.id
userEmail = userData.email
}

const userSubscription = await getHighestPrioritySubscription(resolvedUserId)

if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) {
return badRequestResponse(
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
)
}

let entityType: 'user' | 'organization'
let entityId: string
const plan = userSubscription.plan
let seats: number | null = null

if (plan === 'team' || plan === 'enterprise') {
entityType = 'organization'
entityId = userSubscription.referenceId

const [orgExists] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)

if (!orgExists) {
return notFoundResponse('Organization')
}

const [subData] = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
.limit(1)

seats = getEffectiveSeats(subData)
} else {
entityType = 'user'
entityId = resolvedUserId

const [existingStats] = await db
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)

if (!existingStats) {
await db.insert(userStats).values({
id: nanoid(),
userId: entityId,
})
}
}

await addCredits(entityType, entityId, amount)

let newCreditBalance: number
if (entityType === 'organization') {
const [orgData] = await db
.select({ creditBalance: organization.creditBalance })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0')
} else {
const [stats] = await db
.select({ creditBalance: userStats.creditBalance })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
newCreditBalance = Number.parseFloat(stats?.creditBalance || '0')
}

await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance)

let newUsageLimit: number
if (entityType === 'organization') {
const [orgData] = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0')
} else {
const [stats] = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0')
}

logger.info('Admin API: Issued credits', {
resolvedUserId,
userEmail,
entityType,
entityId,
amount,
newCreditBalance,
newUsageLimit,
reason: reason || 'No reason provided',
})

return singleResponse({
success: true,
userId: resolvedUserId,
userEmail,
entityType,
entityId,
amount,
newCreditBalance,
newUsageLimit,
})
} catch (error) {
logger.error('Admin API: Failed to issue credits', { error })
return internalErrorResponse('Failed to issue credits')
}
})
3 changes: 3 additions & 0 deletions apps/sim/app/api/v1/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@
* GET /api/v1/admin/subscriptions/:id - Get subscription details
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
*
* Credits:
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
*
* Access Control (Permission Groups):
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,7 @@ export function AccessControl() {
)}
</div>

<div className='flex items-center justify-between rounded-[8px] border border-[var(--border)] px-[12px] py-[10px]'>
<div className='flex items-center justify-between'>
<div className='flex flex-col gap-[2px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Auto-add new members
Expand All @@ -705,7 +705,7 @@ export function AccessControl() {
</div>

<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Members
Expand Down