Skip to content

Commit 1e89d14

Browse files
committed
feat(admin): add credits endpoint to issue credits to users
1 parent 9b72b52 commit 1e89d14

File tree

2 files changed

+238
-0
lines changed

2 files changed

+238
-0
lines changed
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/**
2+
* POST /api/v1/admin/credits
3+
*
4+
* Issue credits to a user by user ID or email.
5+
*
6+
* Body:
7+
* - userId?: string - The user ID to issue credits to
8+
* - email?: string - The user email to issue credits to (alternative to userId)
9+
* - amount: number - The amount of credits to issue (in dollars)
10+
* - reason?: string - Reason for issuing credits (for audit logging)
11+
*
12+
* Response: AdminSingleResponse<{
13+
* success: true,
14+
* entityType: 'user' | 'organization',
15+
* entityId: string,
16+
* amount: number,
17+
* newCreditBalance: number,
18+
* newUsageLimit: number,
19+
* }>
20+
*
21+
* For Pro users: credits are added to user_stats.credit_balance
22+
* For Team users: credits are added to organization.credit_balance
23+
* Usage limits are updated accordingly to allow spending the credits.
24+
*/
25+
26+
import { db } from '@sim/db'
27+
import { organization, subscription, user, userStats } from '@sim/db/schema'
28+
import { createLogger } from '@sim/logger'
29+
import { and, eq, sql } from 'drizzle-orm'
30+
import { nanoid } from 'nanoid'
31+
import { getPlanPricing } from '@/lib/billing/core/billing'
32+
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
33+
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
34+
import {
35+
badRequestResponse,
36+
internalErrorResponse,
37+
notFoundResponse,
38+
singleResponse,
39+
} from '@/app/api/v1/admin/responses'
40+
41+
const logger = createLogger('AdminCreditsAPI')
42+
43+
export const POST = withAdminAuth(async (request) => {
44+
try {
45+
const body = await request.json()
46+
const { userId, email, amount, reason } = body
47+
48+
if (!userId && !email) {
49+
return badRequestResponse('Either userId or email is required')
50+
}
51+
52+
if (typeof amount !== 'number' || amount <= 0) {
53+
return badRequestResponse('amount must be a positive number')
54+
}
55+
56+
let resolvedUserId: string
57+
let userEmail: string | null = null
58+
59+
if (userId) {
60+
const [userData] = await db
61+
.select({ id: user.id, email: user.email })
62+
.from(user)
63+
.where(eq(user.id, userId))
64+
.limit(1)
65+
66+
if (!userData) {
67+
return notFoundResponse('User')
68+
}
69+
resolvedUserId = userData.id
70+
userEmail = userData.email
71+
} else {
72+
const normalizedEmail = email.toLowerCase().trim()
73+
const [userData] = await db
74+
.select({ id: user.id, email: user.email })
75+
.from(user)
76+
.where(eq(user.email, normalizedEmail))
77+
.limit(1)
78+
79+
if (!userData) {
80+
return notFoundResponse('User with email')
81+
}
82+
resolvedUserId = userData.id
83+
userEmail = userData.email
84+
}
85+
86+
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
87+
88+
let entityType: 'user' | 'organization'
89+
let entityId: string
90+
let plan: string
91+
let seats: number | null = null
92+
93+
if (userSubscription?.plan === 'team' || userSubscription?.plan === 'enterprise') {
94+
entityType = 'organization'
95+
entityId = userSubscription.referenceId
96+
plan = userSubscription.plan
97+
98+
const [orgExists] = await db
99+
.select({ id: organization.id })
100+
.from(organization)
101+
.where(eq(organization.id, entityId))
102+
.limit(1)
103+
104+
if (!orgExists) {
105+
return notFoundResponse('Organization')
106+
}
107+
108+
const [subData] = await db
109+
.select({ seats: subscription.seats })
110+
.from(subscription)
111+
.where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
112+
.limit(1)
113+
114+
seats = subData?.seats ?? null
115+
} else if (userSubscription?.plan === 'pro') {
116+
entityType = 'user'
117+
entityId = resolvedUserId
118+
plan = 'pro'
119+
} else {
120+
return badRequestResponse(
121+
'User must have an active Pro or Team subscription to receive credits'
122+
)
123+
}
124+
125+
const { basePrice } = getPlanPricing(plan)
126+
127+
const result = await db.transaction(async (tx) => {
128+
let newCreditBalance: number
129+
let newUsageLimit: number
130+
131+
if (entityType === 'organization') {
132+
await tx
133+
.update(organization)
134+
.set({ creditBalance: sql`${organization.creditBalance} + ${amount}` })
135+
.where(eq(organization.id, entityId))
136+
137+
const [orgData] = await tx
138+
.select({
139+
creditBalance: organization.creditBalance,
140+
orgUsageLimit: organization.orgUsageLimit,
141+
})
142+
.from(organization)
143+
.where(eq(organization.id, entityId))
144+
.limit(1)
145+
146+
newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0')
147+
const currentLimit = Number.parseFloat(orgData?.orgUsageLimit || '0')
148+
const planBase = Number(basePrice) * (seats || 1)
149+
const calculatedLimit = planBase + newCreditBalance
150+
151+
if (calculatedLimit > currentLimit) {
152+
await tx
153+
.update(organization)
154+
.set({ orgUsageLimit: calculatedLimit.toString() })
155+
.where(eq(organization.id, entityId))
156+
newUsageLimit = calculatedLimit
157+
} else {
158+
newUsageLimit = currentLimit
159+
}
160+
} else {
161+
const [existingStats] = await tx
162+
.select({ id: userStats.id })
163+
.from(userStats)
164+
.where(eq(userStats.userId, entityId))
165+
.limit(1)
166+
167+
if (!existingStats) {
168+
await tx.insert(userStats).values({
169+
id: nanoid(),
170+
userId: entityId,
171+
creditBalance: amount.toString(),
172+
})
173+
} else {
174+
await tx
175+
.update(userStats)
176+
.set({ creditBalance: sql`${userStats.creditBalance} + ${amount}` })
177+
.where(eq(userStats.userId, entityId))
178+
}
179+
180+
const [stats] = await tx
181+
.select({
182+
creditBalance: userStats.creditBalance,
183+
currentUsageLimit: userStats.currentUsageLimit,
184+
})
185+
.from(userStats)
186+
.where(eq(userStats.userId, entityId))
187+
.limit(1)
188+
189+
newCreditBalance = Number.parseFloat(stats?.creditBalance || '0')
190+
const currentLimit = Number.parseFloat(stats?.currentUsageLimit || '0')
191+
const planBase = Number(basePrice)
192+
const calculatedLimit = planBase + newCreditBalance
193+
194+
if (calculatedLimit > currentLimit) {
195+
await tx
196+
.update(userStats)
197+
.set({ currentUsageLimit: calculatedLimit.toString() })
198+
.where(eq(userStats.userId, entityId))
199+
newUsageLimit = calculatedLimit
200+
} else {
201+
newUsageLimit = currentLimit
202+
}
203+
}
204+
205+
return { newCreditBalance, newUsageLimit }
206+
})
207+
208+
const { newCreditBalance, newUsageLimit } = result
209+
210+
logger.info('Admin API: Issued credits', {
211+
resolvedUserId,
212+
userEmail,
213+
entityType,
214+
entityId,
215+
amount,
216+
newCreditBalance,
217+
newUsageLimit,
218+
reason: reason || 'No reason provided',
219+
})
220+
221+
return singleResponse({
222+
success: true,
223+
userId: resolvedUserId,
224+
userEmail,
225+
entityType,
226+
entityId,
227+
amount,
228+
newCreditBalance,
229+
newUsageLimit,
230+
})
231+
} catch (error) {
232+
logger.error('Admin API: Failed to issue credits', { error })
233+
return internalErrorResponse('Failed to issue credits')
234+
}
235+
})

apps/sim/app/api/v1/admin/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@
6363
* GET /api/v1/admin/subscriptions/:id - Get subscription details
6464
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
6565
*
66+
* Credits:
67+
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
68+
*
6669
* Access Control (Permission Groups):
6770
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
6871
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)

0 commit comments

Comments
 (0)