chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+428
View File
@@ -0,0 +1,428 @@
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
}
}