d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
250 lines
7.7 KiB
TypeScript
250 lines
7.7 KiB
TypeScript
/**
|
|
* 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 { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
|
import { db } from '@sim/db'
|
|
import { organization, subscription, user, userStats } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { generateShortId } from '@sim/utils/id'
|
|
import { normalizeEmail } from '@sim/utils/string'
|
|
import { and, eq, inArray } from 'drizzle-orm'
|
|
import { adminV1IssueCreditsContract } from '@/lib/api/contracts/v1/admin'
|
|
import { parseRequest } from '@/lib/api/server'
|
|
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
|
import { addCredits } from '@/lib/billing/credits/balance'
|
|
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
|
|
import { isPaid } from '@/lib/billing/plan-helpers'
|
|
import {
|
|
ENTITLED_SUBSCRIPTION_STATUSES,
|
|
getEffectiveSeats,
|
|
isOrgScopedSubscription,
|
|
} from '@/lib/billing/subscriptions/utils'
|
|
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
|
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
|
import {
|
|
adminInvalidJsonResponse,
|
|
adminValidationErrorResponse,
|
|
badRequestResponse,
|
|
internalErrorResponse,
|
|
notFoundResponse,
|
|
singleResponse,
|
|
} from '@/app/api/v1/admin/responses'
|
|
|
|
const logger = createLogger('AdminCreditsAPI')
|
|
|
|
export const POST = withRouteHandler(
|
|
withAdminAuth(async (request) => {
|
|
const parsed = await parseRequest(
|
|
adminV1IssueCreditsContract,
|
|
request,
|
|
{},
|
|
{
|
|
validationErrorResponse: adminValidationErrorResponse,
|
|
invalidJsonResponse: adminInvalidJsonResponse,
|
|
}
|
|
)
|
|
if (!parsed.success) return parsed.response
|
|
|
|
try {
|
|
const { userId, email, amount, reason } = parsed.data.body
|
|
|
|
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 {
|
|
if (!email) {
|
|
return badRequestResponse('Either userId or email is required')
|
|
}
|
|
|
|
const normalizedEmail = normalizeEmail(email)
|
|
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 || !isPaid(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
|
|
|
|
// Route admin credits to the subscription's entity (org if org-scoped).
|
|
if (isOrgScopedSubscription(userSubscription, resolvedUserId)) {
|
|
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),
|
|
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
|
)
|
|
)
|
|
.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: generateShortId(),
|
|
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',
|
|
})
|
|
|
|
recordAudit({
|
|
actorId: 'admin-api',
|
|
action: AuditAction.CREDIT_ISSUED,
|
|
resourceType: AuditResourceType.BILLING,
|
|
resourceId: entityId,
|
|
description: `Admin API issued $${Number(amount).toFixed(2)} credits to ${entityType} ${entityId}`,
|
|
metadata: {
|
|
targetUserId: resolvedUserId,
|
|
...(entityType === 'organization'
|
|
? { targetOrgId: entityId, organizationId: entityId }
|
|
: {}),
|
|
entityType,
|
|
amount,
|
|
currency: 'usd',
|
|
reason: reason || null,
|
|
newCreditBalance,
|
|
},
|
|
request,
|
|
})
|
|
|
|
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')
|
|
}
|
|
})
|
|
)
|