Files
simstudioai--sim/apps/sim/lib/billing/credits/balance.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

218 lines
6.9 KiB
TypeScript

import { db } from '@sim/db'
import { organization, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
import {
hasUsableSubscriptionAccess,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { Decimal, toDecimal, toFixedString, toNumber } from '@/lib/billing/utils/decimal'
import type { DbClient } from '@/lib/db/types'
const logger = createLogger('CreditBalance')
export interface CreditBalanceInfo {
balance: number
entityType: 'user' | 'organization'
entityId: string
}
/**
* Read credit balance directly from a known entity (user or organization).
* Use this in webhook / admin paths that already know the target entity —
* unlike `getCreditBalance(userId)` it does not route through
* `getHighestPrioritySubscription`, so callers don't need to resolve the
* org owner as a user-id proxy.
*/
export async function getCreditBalanceForEntity(
entityType: 'user' | 'organization',
entityId: string,
executor: DbClient = db
): Promise<number> {
if (entityType === 'organization') {
const rows = await executor
.select({ creditBalance: organization.creditBalance })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0
}
const rows = await executor
.select({ creditBalance: userStats.creditBalance })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
return rows.length > 0 ? toNumber(toDecimal(rows[0].creditBalance)) : 0
}
export async function getCreditBalance(
userId: string,
executor: DbClient = db
): Promise<CreditBalanceInfo> {
const subscription = await getHighestPrioritySubscription(userId, { executor })
if (isOrgScopedSubscription(subscription, userId) && subscription) {
return {
balance: await getCreditBalanceForEntity('organization', subscription.referenceId, executor),
entityType: 'organization',
entityId: subscription.referenceId,
}
}
return {
balance: await getCreditBalanceForEntity('user', userId, executor),
entityType: 'user',
entityId: userId,
}
}
export async function addCredits(
entityType: 'user' | 'organization',
entityId: string,
amount: number
): Promise<void> {
if (entityType === 'organization') {
await db
.update(organization)
.set({ creditBalance: sql`${organization.creditBalance} + ${amount}` })
.where(eq(organization.id, entityId))
logger.info('Added credits to organization', { organizationId: entityId, amount })
} else {
await db
.update(userStats)
.set({ creditBalance: sql`${userStats.creditBalance} + ${amount}` })
.where(eq(userStats.userId, entityId))
logger.info('Added credits to user', { userId: entityId, amount })
}
}
async function removeCredits(
entityType: 'user' | 'organization',
entityId: string,
amount: number
): Promise<void> {
if (entityType === 'organization') {
await db
.update(organization)
.set({ creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${amount})` })
.where(eq(organization.id, entityId))
logger.info('Removed credits from organization', { organizationId: entityId, amount })
} else {
await db
.update(userStats)
.set({ creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${amount})` })
.where(eq(userStats.userId, entityId))
logger.info('Removed credits from user', { userId: entityId, amount })
}
}
interface DeductResult {
creditsUsed: number
overflow: number
}
async function atomicDeductUserCredits(userId: string, cost: number): Promise<number> {
const costDecimal = toDecimal(cost)
const costStr = toFixedString(costDecimal)
// Use raw SQL with CTE to capture old balance before update
const result = await db.execute<{ old_balance: string; new_balance: string }>(sql`
WITH old_balance AS (
SELECT credit_balance FROM user_stats WHERE user_id = ${userId}
)
UPDATE user_stats
SET credit_balance = CASE
WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal
ELSE 0
END
WHERE user_id = ${userId} AND credit_balance >= 0
RETURNING
(SELECT credit_balance FROM old_balance) as old_balance,
credit_balance as new_balance
`)
const rows = Array.from(result)
if (rows.length === 0) return 0
const oldBalance = toDecimal(rows[0].old_balance)
return toNumber(oldBalance.lessThan(costDecimal) ? oldBalance : costDecimal)
}
async function atomicDeductOrgCredits(orgId: string, cost: number): Promise<number> {
const costDecimal = toDecimal(cost)
const costStr = toFixedString(costDecimal)
// Use raw SQL with CTE to capture old balance before update
const result = await db.execute<{ old_balance: string; new_balance: string }>(sql`
WITH old_balance AS (
SELECT credit_balance FROM organization WHERE id = ${orgId}
)
UPDATE organization
SET credit_balance = CASE
WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal
ELSE 0
END
WHERE id = ${orgId} AND credit_balance >= 0
RETURNING
(SELECT credit_balance FROM old_balance) as old_balance,
credit_balance as new_balance
`)
const rows = Array.from(result)
if (rows.length === 0) return 0
const oldBalance = toDecimal(rows[0].old_balance)
return toNumber(oldBalance.lessThan(costDecimal) ? oldBalance : costDecimal)
}
async function deductFromCredits(userId: string, cost: number): Promise<DeductResult> {
if (cost <= 0) {
return { creditsUsed: 0, overflow: 0 }
}
const subscription = await getHighestPrioritySubscription(userId)
const orgScoped = isOrgScopedSubscription(subscription, userId)
let creditsUsed: number
if (orgScoped && subscription?.referenceId) {
creditsUsed = await atomicDeductOrgCredits(subscription.referenceId, cost)
} else {
creditsUsed = await atomicDeductUserCredits(userId, cost)
}
const overflow = toNumber(Decimal.max(0, toDecimal(cost).minus(creditsUsed)))
if (creditsUsed > 0) {
logger.info('Deducted credits atomically', {
userId,
creditsUsed,
overflow,
entityType: orgScoped ? 'organization' : 'user',
})
}
return { creditsUsed, overflow }
}
export async function canPurchaseCredits(userId: string): Promise<boolean> {
const subscription = await getHighestPrioritySubscription(userId)
if (!subscription) {
return false
}
const billingStatus = await getEffectiveBillingStatus(userId)
if (!hasUsableSubscriptionAccess(subscription.status, billingStatus.billingBlocked)) {
return false
}
// Enterprise users must contact support to purchase credits
return isPro(subscription.plan) || isTeam(subscription.plan)
}