Files
simstudioai--sim/apps/sim/lib/billing/organization.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

429 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { db } from '@sim/db'
import {
member,
organization,
subscription as subscriptionTable,
user,
userStats,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { getOrganizationIdForSubscriptionReference } from '@/lib/billing/core/subscription'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { createOrganizationWithOwner } from '@/lib/billing/organizations/create-organization'
import { isEnterprise, isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
import { attachOwnedWorkspacesToOrganization } from '@/lib/workspaces/organization-workspaces'
const logger = createLogger('BillingOrganization')
type SubscriptionData = {
id: string
plan: string
referenceId: string
status: string | null
seats?: number | null
}
/**
* Check if a user already owns an organization
*/
async function getUserOwnedOrganization(userId: string): Promise<string | null> {
const existingMemberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(and(eq(member.userId, userId), eq(member.role, 'owner')))
.limit(1)
if (existingMemberships.length > 0) {
const [existingOrg] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, existingMemberships[0].organizationId))
.limit(1)
return existingOrg?.id || null
}
return null
}
export async function createOrganizationForTeamPlan(
userId: string,
userName?: string,
userEmail?: string,
organizationSlug?: string
): Promise<string> {
try {
const existingOrgId = await getUserOwnedOrganization(userId)
if (existingOrgId) {
return existingOrgId
}
const organizationName = userName || `${userEmail || 'User'}'s Team`
const slug =
organizationSlug ||
`${userId}-team-${Date.now()}`
.toLowerCase()
.replace(/[^a-z0-9-_]+/g, '-')
.replace(/^-|-$/g, '')
const { organizationId: orgId } = await createOrganizationWithOwner({
ownerUserId: userId,
name: organizationName,
slug,
metadata: {
createdForTeamPlan: true,
originalUserId: userId,
},
})
logger.info('Created organization for team/enterprise plan', {
userId,
organizationId: orgId,
organizationName,
})
return orgId
} catch (error) {
logger.error('Failed to create organization for team/enterprise plan', {
userId,
error,
})
throw error
}
}
export async function ensureOrganizationForTeamSubscription(
subscription: SubscriptionData
): Promise<SubscriptionData> {
if (!isOrgPlan(subscription.plan)) {
return subscription
}
const referencedOrganizationId = await getOrganizationIdForSubscriptionReference(
subscription.referenceId
)
if (referencedOrganizationId) {
return {
...subscription,
referenceId: referencedOrganizationId,
}
}
const userId = subscription.referenceId
logger.info('Creating organization for team subscription', {
subscriptionId: subscription.id,
userId,
})
const existingMembership = await db
.select({
id: member.id,
organizationId: member.organizationId,
role: member.role,
})
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (existingMembership.length > 0) {
const membership = existingMembership[0]
if (isOrgAdminRole(membership.role)) {
/**
* Atomic duplicate-subscription check + referenceId transfer.
*
* Row-level locks (`FOR UPDATE`) on the subscription and target
* organization rows prevent a TOCTOU race between the "org has no
* paid subscription" check and the transfer write — which could
* otherwise let two concurrent webhook deliveries or org-creation
* flows both pass the check and attach two subscriptions to the
* same organization.
*/
await db.transaction(async (tx) => {
const [lockedSub] = await tx
.select({
id: subscriptionTable.id,
referenceId: subscriptionTable.referenceId,
})
.from(subscriptionTable)
.where(eq(subscriptionTable.id, subscription.id))
.for('update')
if (!lockedSub) {
throw new Error(`Subscription ${subscription.id} not found during transfer`)
}
if (lockedSub.referenceId === membership.organizationId) {
return
}
const [lockedOrg] = await tx
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, membership.organizationId))
.for('update')
if (!lockedOrg) {
throw new Error(`Organization ${membership.organizationId} not found during transfer`)
}
const [existingOrgSub] = await tx
.select({ id: subscriptionTable.id })
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, membership.organizationId),
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
if (existingOrgSub) {
logger.error('Organization already has an active subscription', {
userId,
organizationId: membership.organizationId,
newSubscriptionId: subscription.id,
})
throw new Error('Organization already has an active subscription')
}
await tx
.update(subscriptionTable)
.set({ referenceId: membership.organizationId })
.where(eq(subscriptionTable.id, subscription.id))
})
logger.info('User already owns/admins an org, using it', {
userId,
organizationId: membership.organizationId,
})
await attachOwnedWorkspacesToOrganization({
ownerUserId: userId,
organizationId: membership.organizationId,
externalMemberPolicy: 'keep-external',
})
return { ...subscription, referenceId: membership.organizationId }
}
logger.error('User is member of org but not owner/admin - cannot create team subscription', {
userId,
existingOrgId: membership.organizationId,
subscriptionId: subscription.id,
})
throw new Error('User is already member of another organization')
}
const [userData] = await db
.select({ name: user.name, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
const orgId = await createOrganizationForTeamPlan(
userId,
userData?.name || undefined,
userData?.email || undefined
)
await db
.update(subscriptionTable)
.set({ referenceId: orgId })
.where(eq(subscriptionTable.id, subscription.id))
await attachOwnedWorkspacesToOrganization({
ownerUserId: userId,
organizationId: orgId,
externalMemberPolicy: 'keep-external',
})
logger.info('Created organization and updated subscription referenceId', {
subscriptionId: subscription.id,
userId,
organizationId: orgId,
})
return { ...subscription, referenceId: orgId }
}
/**
* Sync usage limits for subscription members
* Updates usage limits for all users associated with the subscription
*/
export async function syncSubscriptionUsageLimits(subscription: SubscriptionData) {
try {
logger.info('Syncing subscription usage limits', {
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
plan: subscription.plan,
})
const organizationId = await getOrganizationIdForSubscriptionReference(subscription.referenceId)
if (!organizationId) {
const users = await db
.select({ id: user.id })
.from(user)
.where(eq(user.id, subscription.referenceId))
.limit(1)
if (users.length === 0) {
throw new Error(
`Subscription reference ${subscription.referenceId} does not match a user or organization`
)
}
// Individual user subscription - sync their usage limits
await syncUsageLimitsFromSubscription(subscription.referenceId)
logger.info('Synced usage limits for individual user subscription', {
userId: subscription.referenceId,
subscriptionId: subscription.id,
plan: subscription.plan,
})
} else {
// Organization subscription - set org usage limit and sync member limits
// Set orgUsageLimit for any paid non-enterprise plan attached to
// the org. Enterprise is set via webhook with custom pricing.
// Min = basePrice × seats, mirroring Stripe's `price × quantity`.
if (isPaid(subscription.plan) && !isEnterprise(subscription.plan)) {
const { basePrice } = getPlanPricing(subscription.plan)
const seats = subscription.seats || 1
const orgLimit = seats * basePrice
// Only set if not already set or if updating to a higher value based on seats
const orgData = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
const currentLimit =
orgData.length > 0 && orgData[0].orgUsageLimit
? toNumber(toDecimal(orgData[0].orgUsageLimit))
: 0
// Update if no limit set, or if new seat-based minimum is higher
if (currentLimit < orgLimit) {
await db
.update(organization)
.set({
orgUsageLimit: orgLimit.toFixed(2),
updatedAt: new Date(),
})
.where(eq(organization.id, organizationId))
logger.info('Set organization usage limit', {
organizationId,
plan: subscription.plan,
seats,
basePrice,
orgLimit,
previousLimit: currentLimit,
})
}
}
// Sync usage limits for all members
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, organizationId))
if (members.length > 0) {
for (const m of members) {
try {
await syncUsageLimitsFromSubscription(m.userId)
} catch (memberError) {
logger.error('Failed to sync usage limits for organization member', {
userId: m.userId,
organizationId,
subscriptionId: subscription.id,
error: memberError,
})
}
}
logger.info('Synced usage limits for organization members', {
organizationId,
memberCount: members.length,
subscriptionId: subscription.id,
plan: subscription.plan,
})
// Bulk version of the per-member transfer in invitation-accept:
// catches members whose personal bytes never made it into the
// org pool (e.g. org upgraded free → paid after they joined).
// `.for('update')` row-locks so concurrent increment/decrement
// calls cannot slip between the snapshot SELECT and the
// zeroing UPDATE and get silently dropped. Idempotent — zeroed
// rows are filtered out.
if (isPaid(subscription.plan)) {
try {
const memberIds = members.map((m) => m.userId)
await db.transaction(async (tx) => {
const personalStorageRows = await tx
.select({
userId: userStats.userId,
bytes: userStats.storageUsedBytes,
})
.from(userStats)
.where(inArray(userStats.userId, memberIds))
.for('update')
const toTransfer = personalStorageRows.filter((r) => (r.bytes ?? 0) > 0)
const totalBytes = toTransfer.reduce((acc, r) => acc + (r.bytes ?? 0), 0)
if (totalBytes === 0) return
await tx
.update(organization)
.set({
storageUsedBytes: sql`${organization.storageUsedBytes} + ${totalBytes}`,
})
.where(eq(organization.id, organizationId))
await tx
.update(userStats)
.set({ storageUsedBytes: 0 })
.where(
inArray(
userStats.userId,
toTransfer.map((r) => r.userId)
)
)
logger.info('Transferred personal storage bytes to org pool during sync', {
organizationId,
subscriptionId: subscription.id,
memberCount: toTransfer.length,
totalBytes,
})
})
} catch (storageError) {
logger.error('Failed to transfer personal storage to org pool', {
organizationId,
subscriptionId: subscription.id,
error: storageError,
})
}
}
}
}
} catch (error) {
logger.error('Failed to sync subscription usage limits', {
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
error,
})
throw error
}
}