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
@@ -0,0 +1,88 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGenerateId } = vi.hoisted(() => ({
mockGenerateId: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@sim/utils/id', () => ({
generateId: mockGenerateId,
generateShortId: vi.fn(() => 'short-id'),
}))
import {
createOrganizationWithOwner,
OrganizationSlugTakenError,
validateOrganizationSlugOrThrow,
} from '@/lib/billing/organizations/create-organization'
function insertedValuesFor(predicate: (values: Record<string, unknown>) => boolean) {
return dbChainMockFns.values.mock.calls
.map((call) => call[0] as Record<string, unknown>)
.filter(predicate)
}
describe('createOrganizationWithOwner', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates an organization with a Better Auth-compatible id prefix', async () => {
mockGenerateId.mockReturnValueOnce('abc123').mockReturnValueOnce('member456')
dbChainMockFns.limit.mockResolvedValueOnce([])
const result = await createOrganizationWithOwner({
ownerUserId: 'user-1',
name: 'My Org',
slug: 'my-org',
metadata: { source: 'test' },
})
expect(result).toEqual({
organizationId: 'org_abc123',
memberId: 'member456',
})
expect(insertedValuesFor((v) => 'slug' in v)).toEqual([
expect.objectContaining({
id: 'org_abc123',
name: 'My Org',
slug: 'my-org',
metadata: { source: 'test' },
}),
])
expect(insertedValuesFor((v) => !('slug' in v))).toEqual([
expect.objectContaining({
id: 'member456',
userId: 'user-1',
organizationId: 'org_abc123',
role: 'owner',
}),
])
})
it('throws a typed error when the organization slug is already taken', async () => {
mockGenerateId.mockReturnValueOnce('abc123').mockReturnValueOnce('member456')
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'existing-org' }])
await expect(
createOrganizationWithOwner({
ownerUserId: 'user-1',
name: 'My Org',
slug: 'my-org',
})
).rejects.toBeInstanceOf(OrganizationSlugTakenError)
expect(insertedValuesFor(() => true)).toEqual([])
})
it('rejects invalid organization slugs before writing anything', () => {
expect(() => validateOrganizationSlugOrThrow('Invalid Slug!')).toThrow(
'Organization slug "Invalid Slug!" is invalid'
)
})
})
@@ -0,0 +1,110 @@
import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq, ne } from 'drizzle-orm'
const ORGANIZATION_SLUG_REGEX = /^[a-z0-9-_]+$/
export class OrganizationSlugInvalidError extends Error {
constructor(slug: string) {
super(`Organization slug "${slug}" is invalid`)
this.name = 'OrganizationSlugInvalidError'
}
}
export class OrganizationSlugTakenError extends Error {
constructor(slug: string) {
super(`Organization slug "${slug}" is already taken`)
this.name = 'OrganizationSlugTakenError'
}
}
interface CreateOrganizationWithOwnerParams {
ownerUserId: string
name: string
slug: string
metadata?: Record<string, unknown>
}
interface EnsureOrganizationSlugAvailableParams {
slug: string
excludeOrganizationId?: string
}
interface CreateOrganizationWithOwnerResult {
organizationId: string
memberId: string
}
export function validateOrganizationSlugOrThrow(slug: string): void {
if (!ORGANIZATION_SLUG_REGEX.test(slug)) {
throw new OrganizationSlugInvalidError(slug)
}
}
export async function ensureOrganizationSlugAvailable({
slug,
excludeOrganizationId,
}: EnsureOrganizationSlugAvailableParams): Promise<void> {
const whereClause = excludeOrganizationId
? and(eq(organization.slug, slug), ne(organization.id, excludeOrganizationId))
: eq(organization.slug, slug)
const existingOrganization = await db
.select({ id: organization.id })
.from(organization)
.where(whereClause)
.limit(1)
if (existingOrganization.length > 0) {
throw new OrganizationSlugTakenError(slug)
}
}
export async function createOrganizationWithOwner({
ownerUserId,
name,
slug,
metadata = {},
}: CreateOrganizationWithOwnerParams): Promise<CreateOrganizationWithOwnerResult> {
validateOrganizationSlugOrThrow(slug)
const organizationId = `org_${generateId()}`
const memberId = generateId()
const now = new Date()
await db.transaction(async (tx) => {
const whereClause = eq(organization.slug, slug)
const existingOrganization = await tx
.select({ id: organization.id })
.from(organization)
.where(whereClause)
.limit(1)
if (existingOrganization.length > 0) {
throw new OrganizationSlugTakenError(slug)
}
await tx.insert(organization).values({
id: organizationId,
name,
slug,
metadata,
createdAt: now,
updatedAt: now,
})
await tx.insert(member).values({
id: memberId,
userId: ownerUserId,
organizationId,
role: 'owner',
createdAt: now,
})
})
return {
organizationId,
memberId,
}
}
@@ -0,0 +1,97 @@
/**
* @vitest-environment node
*
* Lock-order regression guard: the paid-org join billing transaction must lock
* the personal Pro subscription BEFORE userStats, matching
* restoreUserProSubscription's subscription → userStats order. Snapshotting
* userStats before locking the subscription inverts that pair and deadlocks a
* concurrent Pro restore for the same user.
*/
import { subscription as subscriptionTable, userStats } from '@sim/db/schema'
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { reapplyPaidOrgJoinBillingForExistingMember } from '@/lib/billing/organizations/membership'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/core/outbox/service', () => ({
enqueueOutboxEvent: vi.fn(),
}))
/**
* A superset row that satisfies every read in the join path: a paid org sub, a
* still-active personal Pro to pause, non-zero usage to snapshot, and zero
* storage (so the conditional org storage-transfer write is skipped — the org
* lock under test is the canonical pre-lock, not the storage update).
*/
const GENERIC_ROW = {
id: 'sub-1',
plan: 'team',
referenceId: 'user-1',
status: 'active',
cancelAtPeriodEnd: false,
stripeSubscriptionId: 'stripe-1',
currentPeriodCost: '5',
proPeriodCostSnapshot: '0',
storageUsedBytes: 0,
}
type LockOp = { op: 'lock' | 'update' | 'insert'; table: unknown }
function createRecordingTx() {
const ops: LockOp[] = []
const select = () => {
const ctx: { table: unknown } = { table: undefined }
const chain = {
from: (table: unknown) => {
ctx.table = table
return chain
},
where: () => chain,
for: () => {
ops.push({ op: 'lock', table: ctx.table })
return chain
},
limit: async () => [GENERIC_ROW],
}
return chain
}
const tx = {
select,
update: (table: unknown) => ({
set: () => ({
where: async () => {
ops.push({ op: 'update', table })
},
}),
}),
insert: (table: unknown) => ({
values: async () => {
ops.push({ op: 'insert', table })
},
}),
execute: async () => [],
}
return { tx, ops }
}
describe('paid-org join billing lock ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('locks the personal subscription before mutating userStats', async () => {
const { tx, ops } = createRecordingTx()
dbChainMockFns.transaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx))
await reapplyPaidOrgJoinBillingForExistingMember('user-1', 'org-1')
const firstUserStatsUpdate = ops.findIndex((o) => o.op === 'update' && o.table === userStats)
const subscriptionLock = ops.findIndex((o) => o.op === 'lock' && o.table === subscriptionTable)
expect(firstUserStatsUpdate).toBeGreaterThanOrEqual(0)
expect(subscriptionLock).toBeGreaterThanOrEqual(0)
expect(subscriptionLock).toBeLessThan(firstUserStatsUpdate)
})
})
@@ -0,0 +1,161 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockDbState,
mockInsert,
mockInsertValues,
mockOnConflictDoUpdate,
mockDelete,
mockDeleteWhere,
mockGetOrganizationSubscription,
mockGetOrgWorkspaceUsageCostForUser,
} = vi.hoisted(() => ({
mockDbState: { selectResults: [] as unknown[] },
mockInsert: vi.fn(),
mockInsertValues: vi.fn(),
mockOnConflictDoUpdate: vi.fn(),
mockDelete: vi.fn(),
mockDeleteWhere: vi.fn(),
mockGetOrganizationSubscription: vi.fn(),
mockGetOrgWorkspaceUsageCostForUser: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(() => {
const chain: Record<string, unknown> = {}
chain.from = vi.fn(() => chain)
chain.where = vi.fn(() => chain)
chain.limit = vi.fn(() => Promise.resolve(mockDbState.selectResults.shift() ?? []))
chain.then = (cb: (rows: unknown) => unknown) =>
Promise.resolve(cb(mockDbState.selectResults.shift() ?? []))
return chain
}),
insert: mockInsert,
delete: mockDelete,
},
}))
vi.mock('@sim/db/schema', () => ({
organizationMemberUsageLimit: {
id: 'oml.id',
organizationId: 'oml.organizationId',
userId: 'oml.userId',
usageLimit: 'oml.usageLimit',
setBy: 'oml.setBy',
createdAt: 'oml.createdAt',
updatedAt: 'oml.updatedAt',
},
workspace: { id: 'workspace.id', organizationId: 'workspace.organizationId' },
}))
vi.mock('@/lib/billing/core/billing', () => ({
getOrganizationSubscription: mockGetOrganizationSubscription,
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
getOrgWorkspaceUsageCostForUser: mockGetOrgWorkspaceUsageCostForUser,
}))
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import {
getOrgMemberUsageLimit,
getOrgMemberWorkspaceUsage,
setOrgMemberUsageLimit,
} from '@/lib/billing/organizations/member-limits'
beforeEach(() => {
vi.clearAllMocks()
mockDbState.selectResults = []
mockInsert.mockReturnValue({ values: mockInsertValues })
mockInsertValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate })
mockOnConflictDoUpdate.mockResolvedValue(undefined)
mockDelete.mockReturnValue({ where: mockDeleteWhere })
mockDeleteWhere.mockResolvedValue(undefined)
})
describe('getOrgMemberUsageLimit', () => {
it('returns null when no row exists', async () => {
mockDbState.selectResults = [[]]
await expect(getOrgMemberUsageLimit('org-1', 'user-2')).resolves.toBeNull()
})
it('returns the stored dollar limit as a number', async () => {
mockDbState.selectResults = [[{ usageLimit: '2' }]]
await expect(getOrgMemberUsageLimit('org-1', 'user-2')).resolves.toBe(2)
})
})
describe('setOrgMemberUsageLimit', () => {
it('upserts when given a dollar amount', async () => {
await setOrgMemberUsageLimit('org-1', 'user-2', 2, 'admin-1')
expect(mockInsert).toHaveBeenCalledTimes(1)
expect(mockDelete).not.toHaveBeenCalled()
const values = mockInsertValues.mock.calls[0][0]
expect(values).toMatchObject({
organizationId: 'org-1',
userId: 'user-2',
usageLimit: '2',
setBy: 'admin-1',
})
expect(mockOnConflictDoUpdate).toHaveBeenCalledTimes(1)
})
it('deletes the row when limit is null', async () => {
await setOrgMemberUsageLimit('org-1', 'user-2', null, 'admin-1')
expect(mockDelete).toHaveBeenCalledTimes(1)
expect(mockDeleteWhere).toHaveBeenCalledTimes(1)
expect(mockInsert).not.toHaveBeenCalled()
})
})
describe('getOrgMemberWorkspaceUsage', () => {
it("returns the member's usage within the org subscription window", async () => {
const periodStart = new Date('2026-06-01T00:00:00.000Z')
const periodEnd = new Date('2026-07-01T00:00:00.000Z')
mockGetOrganizationSubscription.mockResolvedValue({ periodStart, periodEnd })
mockGetOrgWorkspaceUsageCostForUser.mockResolvedValue(5)
const result = await getOrgMemberWorkspaceUsage('org-1', 'user-2')
expect(result).toBe(5)
expect(mockGetOrgWorkspaceUsageCostForUser).toHaveBeenCalledWith('org-1', 'user-2', {
start: periodStart,
end: periodEnd,
})
})
it('falls back to the all-time window when the org has no subscription', async () => {
mockGetOrganizationSubscription.mockResolvedValue(null)
mockGetOrgWorkspaceUsageCostForUser.mockResolvedValue(7)
const result = await getOrgMemberWorkspaceUsage('org-1', 'user-2')
expect(result).toBe(7)
expect(mockGetOrgWorkspaceUsageCostForUser).toHaveBeenCalledWith(
'org-1',
'user-2',
defaultBillingPeriod()
)
})
it('falls back to the all-time window when the subscription is missing periodEnd', async () => {
mockGetOrganizationSubscription.mockResolvedValue({
periodStart: new Date('2026-06-01T00:00:00.000Z'),
periodEnd: null,
})
mockGetOrgWorkspaceUsageCostForUser.mockResolvedValue(3)
const result = await getOrgMemberWorkspaceUsage('org-1', 'user-2')
expect(result).toBe(3)
expect(mockGetOrgWorkspaceUsageCostForUser).toHaveBeenCalledWith(
'org-1',
'user-2',
defaultBillingPeriod()
)
})
})
@@ -0,0 +1,114 @@
import { db } from '@sim/db'
import { organizationMemberUsageLimit } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import { getOrgWorkspaceUsageCostForUser } from '@/lib/billing/core/usage-log'
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
const logger = createLogger('OrgMemberLimits')
/**
* Read a member's per-organization usage limit (dollars). Returns `null` when no
* cap is set for the `(organization, user)` pair — meaning only the pooled org
* limit applies. Independent of `user_stats.current_usage_limit` (the user's
* personal subscription cap), so it covers external members without clobbering
* their personal limit.
*/
export async function getOrgMemberUsageLimit(
organizationId: string,
userId: string
): Promise<number | null> {
const rows = await db
.select({ usageLimit: organizationMemberUsageLimit.usageLimit })
.from(organizationMemberUsageLimit)
.where(
and(
eq(organizationMemberUsageLimit.organizationId, organizationId),
eq(organizationMemberUsageLimit.userId, userId)
)
)
.limit(1)
if (rows.length === 0) return null
return toNumber(toDecimal(rows[0].usageLimit))
}
/**
* Upsert (or clear) a member's per-organization usage limit. Passing `null` for
* `limitDollars` deletes the row, removing the per-member cap. The target need
* not be an organization `member` row, so external members are supported.
*/
export async function setOrgMemberUsageLimit(
organizationId: string,
userId: string,
limitDollars: number | null,
setBy?: string
): Promise<void> {
if (limitDollars === null) {
await db
.delete(organizationMemberUsageLimit)
.where(
and(
eq(organizationMemberUsageLimit.organizationId, organizationId),
eq(organizationMemberUsageLimit.userId, userId)
)
)
logger.info('Cleared per-member usage limit', { organizationId, userId, setBy })
return
}
await db
.insert(organizationMemberUsageLimit)
.values({
id: generateId(),
organizationId,
userId,
usageLimit: limitDollars.toString(),
setBy: setBy ?? null,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [organizationMemberUsageLimit.organizationId, organizationMemberUsageLimit.userId],
set: {
usageLimit: limitDollars.toString(),
setBy: setBy ?? null,
updatedAt: new Date(),
},
})
logger.info('Set per-member usage limit', { organizationId, userId, limitDollars, setBy })
}
/**
* Compute a member's current-period usage (dollars) inside the organization's
* own workspaces.
*
* Sums `usage_log` by `created_at` within the org subscription window across the
* org's workspaces, scoped to the given user (a single indexed aggregation — see
* {@link getOrgWorkspaceUsageCostForUser}). Filtering by workspace (not billing
* entity) is what captures external members and mothership/copilot cost. Raw
* usage — daily-refresh credits are a pooled concept and intentionally not
* deducted here.
*
* When the org has no resolvable subscription window, falls back to the open
* (all-time) window, matching how the rest of the billing layer resolves a
* missing period (e.g. {@link deriveBillingContext}, the pooled-org and user
* usage paths). A hosted org with a per-member cap normally has a period, so
* this fallback is an edge case; using the shared convention keeps this path
* consistent with every other usage read rather than special-casing it.
*/
export async function getOrgMemberWorkspaceUsage(
organizationId: string,
userId: string
): Promise<number> {
const subscription = await getOrganizationSubscription(organizationId)
const billingPeriod =
subscription?.periodStart && subscription.periodEnd
? { start: subscription.periodStart, end: subscription.periodEnd }
: defaultBillingPeriod()
return getOrgWorkspaceUsageCostForUser(organizationId, userId, billingPeriod)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,247 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetOrganizationSubscription,
mockGetHighestPriorityPersonalSubscription,
mockEnsureOrganizationForTeamSubscription,
mockGetPlanByName,
enqueueMock,
updateCalls,
} = vi.hoisted(() => ({
mockGetOrganizationSubscription: vi.fn(),
mockGetHighestPriorityPersonalSubscription: vi.fn(),
mockEnsureOrganizationForTeamSubscription: vi.fn(),
mockGetPlanByName: vi.fn(),
enqueueMock: vi.fn(),
updateCalls: { value: [] as Array<Record<string, unknown>> },
}))
vi.mock('@sim/db', () => {
const update = () => ({
set: (values: Record<string, unknown>) => {
updateCalls.value.push(values)
return { where: () => Promise.resolve([]) }
},
})
const txMock = { update }
const dbMock = {
update,
transaction: async (cb: (tx: typeof txMock) => Promise<unknown>) => cb(txMock),
}
return { db: dbMock }
})
vi.mock('@/lib/billing/core/billing', () => ({
getOrganizationSubscription: mockGetOrganizationSubscription,
}))
vi.mock('@/lib/billing/core/plan', () => ({
getHighestPriorityPersonalSubscription: mockGetHighestPriorityPersonalSubscription,
}))
vi.mock('@/lib/billing/organization', () => ({
ensureOrganizationForTeamSubscription: mockEnsureOrganizationForTeamSubscription,
}))
vi.mock('@/lib/billing/plans', () => ({
getPlanByName: mockGetPlanByName,
}))
vi.mock('@/lib/core/outbox/service', () => ({
enqueueOutboxEvent: enqueueMock,
}))
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({
OUTBOX_EVENT_TYPES: {
STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end',
STRIPE_SYNC_SUBSCRIPTION_SEATS: 'stripe.sync-subscription-seats',
},
}))
import { ensureTeamOrganizationForAcceptance } from '@/lib/billing/organizations/provision-seat'
describe('ensureTeamOrganizationForAcceptance', () => {
beforeEach(() => {
vi.clearAllMocks()
updateCalls.value = []
mockGetPlanByName.mockReturnValue({
priceId: 'price_team_month',
annualDiscountPriceId: 'price_team_year',
})
})
it('is a no-op for enterprise organizations (fixed seats)', async () => {
mockGetOrganizationSubscription.mockResolvedValue({
id: 'sub-ent',
plan: 'enterprise',
status: 'active',
stripeSubscriptionId: 'stripe_sub',
seats: 5,
})
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: 'org-1',
})
expect(result).toEqual({ success: true, organizationId: 'org-1', fixedSeats: true })
expect(updateCalls.value).toHaveLength(0)
})
it('is a no-op for an existing Team organization (org + plan already correct)', async () => {
mockGetOrganizationSubscription.mockResolvedValue({
id: 'sub-team',
plan: 'team_6000',
status: 'active',
seats: 1,
stripeSubscriptionId: 'stripe_sub',
})
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: 'org-1',
})
expect(result).toEqual({ success: true, organizationId: 'org-1', fixedSeats: false })
expect(updateCalls.value).toHaveLength(0)
})
it('moves an org-scoped Pro subscription to the equivalent Team plan', async () => {
mockGetOrganizationSubscription.mockResolvedValue({
id: 'sub-pro',
plan: 'pro_6000',
status: 'active',
seats: 1,
stripeSubscriptionId: 'stripe_sub',
})
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: 'org-1',
})
expect(result).toEqual({ success: true, organizationId: 'org-1', fixedSeats: false })
expect(updateCalls.value).toContainEqual(expect.objectContaining({ plan: 'team_6000' }))
// The Pro→Team price migration is durably enqueued at conversion time.
expect(enqueueMock).toHaveBeenCalledWith(
expect.anything(),
'stripe.sync-subscription-seats',
expect.objectContaining({ subscriptionId: 'sub-pro' })
)
})
it('returns upgrade-required when the personal owner has no usable subscription', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null)
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: null,
})
expect(result).toEqual({ success: false, failureCode: 'upgrade-required' })
expect(mockEnsureOrganizationForTeamSubscription).not.toHaveBeenCalled()
})
it('converts a personal Pro subscription into a Team organization', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
id: 'sub-pro',
plan: 'pro_6000',
status: 'active',
stripeSubscriptionId: 'stripe_sub',
cancelAtPeriodEnd: false,
})
mockEnsureOrganizationForTeamSubscription.mockResolvedValue({ referenceId: 'org-new' })
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: null,
})
expect(result).toEqual({ success: true, organizationId: 'org-new', fixedSeats: false })
expect(updateCalls.value).toContainEqual(
expect.objectContaining({ plan: 'team_6000', cancelAtPeriodEnd: false })
)
expect(mockEnsureOrganizationForTeamSubscription).toHaveBeenCalledWith(
expect.objectContaining({ plan: 'team_6000', referenceId: 'owner-1' })
)
// The plan change enqueues the price seat-sync...
expect(enqueueMock).toHaveBeenCalledWith(
expect.anything(),
'stripe.sync-subscription-seats',
expect.objectContaining({ subscriptionId: 'sub-pro' })
)
// ...but with no scheduled cancellation there is no cancel-sync event.
expect(enqueueMock).not.toHaveBeenCalledWith(
expect.anything(),
'stripe.sync-cancel-at-period-end',
expect.anything()
)
})
it('clears a scheduled cancellation when converting a Pro subscription', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
id: 'sub-pro',
plan: 'pro_6000',
status: 'active',
stripeSubscriptionId: 'stripe_sub',
cancelAtPeriodEnd: true,
})
mockEnsureOrganizationForTeamSubscription.mockResolvedValue({ referenceId: 'org-new' })
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: null,
})
expect(result.success).toBe(true)
expect(enqueueMock).toHaveBeenCalledWith(
expect.anything(),
'stripe.sync-cancel-at-period-end',
expect.objectContaining({ stripeSubscriptionId: 'stripe_sub', subscriptionId: 'sub-pro' })
)
})
it('provisions an org for a legacy personal-scoped Team subscription without a plan change', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
id: 'sub-team',
plan: 'team',
status: 'active',
stripeSubscriptionId: 'stripe_sub',
})
mockEnsureOrganizationForTeamSubscription.mockResolvedValue({ referenceId: 'org-new' })
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: null,
})
expect(result).toEqual({ success: true, organizationId: 'org-new', fixedSeats: false })
expect(mockEnsureOrganizationForTeamSubscription).toHaveBeenCalledWith(
expect.objectContaining({ plan: 'team', referenceId: 'owner-1' })
)
// No plan change and no scheduled cancellation: nothing to push to Stripe.
expect(enqueueMock).not.toHaveBeenCalled()
})
it('returns upgrade-required (no downgrade) when no eligible Team tier exists', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
id: 'sub-max',
plan: 'pro_25000',
status: 'active',
stripeSubscriptionId: 'stripe_sub',
})
// Team Max price is unconfigured in this deployment.
mockGetPlanByName.mockReturnValue(undefined)
const result = await ensureTeamOrganizationForAcceptance({
billingOwnerUserId: 'owner-1',
workspaceOrganizationId: null,
})
expect(result).toEqual({ success: false, failureCode: 'upgrade-required' })
expect(mockEnsureOrganizationForTeamSubscription).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,255 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { subscription as subscriptionTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { CREDIT_TIERS } from '@/lib/billing/constants'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan'
import { ensureOrganizationForTeamSubscription } from '@/lib/billing/organization'
import {
buildPlanName,
getPlanTierCredits,
isEnterprise,
isPro,
isTeam,
} from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('ProvisionSeat')
interface RecordPlanConversionParams {
organizationId: string
actorId: string
fromPlan: string
toPlan: string
}
/**
* Record telemetry for a Pro→Team plan conversion triggered by invite
* acceptance. Fire-and-forget — `recordAudit` and `captureServerEvent` never
* throw, so this can never break the conversion flow. The billing interval is
* preserved across the conversion, so it is reported as `unchanged`.
*/
function recordPlanConversion({
organizationId,
actorId,
fromPlan,
toPlan,
}: RecordPlanConversionParams): void {
recordAudit({
workspaceId: null,
actorId,
action: AuditAction.ORG_PLAN_CONVERTED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: `Converted ${fromPlan} to ${toPlan}`,
metadata: {
fromPlan,
toPlan,
trigger: 'invite-acceptance',
},
})
captureServerEvent(actorId, 'subscription_changed', {
from_plan: fromPlan,
to_plan: toPlan,
interval: 'unchanged',
})
}
export type EnsureTeamOrganizationFailureCode = 'upgrade-required' | 'server-error'
export type EnsureTeamOrganizationResult =
| { success: true; organizationId: string; fixedSeats: boolean }
| { success: false; failureCode: EnsureTeamOrganizationFailureCode }
interface EnsureTeamOrganizationParams {
billingOwnerUserId: string
workspaceOrganizationId: string | null
}
/**
* Ensure the organization an invitee is about to enter exists and is on a Team
* plan, WITHOUT calling Stripe. Returns the organization to add the member to
* and whether seats are fixed (Enterprise).
*
* Seat purchasing is intentionally decoupled from this synchronous path: after
* the member is added, the caller reconciles the seat count and the actual
* Stripe charge happens asynchronously via the seat-sync outbox. A failed
* charge then blocks the org through the existing billing-blocked system rather
* than blocking acceptance synchronously. This keeps the accept path pure-DB,
* with no external call held under a lock.
*
* - Organization (Team): no-op; the org already exists on a Team plan.
* - Organization (Enterprise): `fixedSeats: true` — the caller keeps the
* fixed-seat validation and seats do not auto-grow.
* - Organization (org-scoped Pro): move the plan to the equivalent Team tier.
* - Personal/grandfathered (Pro, or legacy personal Team): create the org,
* attach workspaces, and move to a Team plan.
* - Free / no usable subscription / no eligible Team tier: `upgrade-required`.
*/
export async function ensureTeamOrganizationForAcceptance(
params: EnsureTeamOrganizationParams
): Promise<EnsureTeamOrganizationResult> {
const { billingOwnerUserId, workspaceOrganizationId } = params
try {
if (workspaceOrganizationId) {
return await ensureOrganizationOnTeamPlan(workspaceOrganizationId, billingOwnerUserId)
}
return await convertPersonalSubscriptionToTeam(billingOwnerUserId)
} catch (error) {
logger.error('Failed to ensure team organization for acceptance', {
billingOwnerUserId,
workspaceOrganizationId,
error,
})
return { success: false, failureCode: 'server-error' }
}
}
async function ensureOrganizationOnTeamPlan(
organizationId: string,
actorId: string
): Promise<EnsureTeamOrganizationResult> {
const orgSub = await getOrganizationSubscription(organizationId)
if (!orgSub || !hasUsableSubscriptionStatus(orgSub.status)) {
return { success: false, failureCode: 'upgrade-required' }
}
if (isEnterprise(orgSub.plan)) {
return { success: true, organizationId, fixedSeats: true }
}
if (!isTeam(orgSub.plan)) {
const targetPlan = mapToTeamPlanName(orgSub.plan)
if (!targetPlan) {
return { success: false, failureCode: 'upgrade-required' }
}
await activateTeamSubscription(orgSub, targetPlan, { planChanged: true })
recordPlanConversion({
organizationId,
actorId,
fromPlan: orgSub.plan,
toPlan: targetPlan,
})
}
return { success: true, organizationId, fixedSeats: false }
}
async function convertPersonalSubscriptionToTeam(
userId: string
): Promise<EnsureTeamOrganizationResult> {
const personalSub = await getHighestPriorityPersonalSubscription(userId)
if (!personalSub || !hasUsableSubscriptionStatus(personalSub.status)) {
return { success: false, failureCode: 'upgrade-required' }
}
const alreadyTeam = isTeam(personalSub.plan)
if (!alreadyTeam && !isPro(personalSub.plan)) {
return { success: false, failureCode: 'upgrade-required' }
}
const targetPlan = mapToTeamPlanName(personalSub.plan)
if (!targetPlan) {
return { success: false, failureCode: 'upgrade-required' }
}
await activateTeamSubscription(personalSub, targetPlan, { planChanged: !alreadyTeam })
const updated = await ensureOrganizationForTeamSubscription({
id: personalSub.id,
plan: targetPlan,
referenceId: userId,
status: personalSub.status,
seats: personalSub.seats ?? 1,
})
logger.info('Converted personal subscription to Team on invite acceptance', {
userId,
organizationId: updated.referenceId,
plan: targetPlan,
upgradedFromPro: !alreadyTeam,
})
if (!alreadyTeam) {
recordPlanConversion({
organizationId: updated.referenceId,
actorId: userId,
fromPlan: personalSub.plan,
toPlan: targetPlan,
})
}
return { success: true, organizationId: updated.referenceId, fixedSeats: false }
}
/**
* Activate a subscription as a Team plan in one transaction and durably enqueue
* the Stripe reconciliation. When the plan actually changes (Pro→Team), the
* seat-sync event is enqueued so the handler migrates the Stripe price (and
* quantity) at processing time — guaranteeing the price change lands even if
* the post-join seat reconcile is skipped or fails. Any scheduled cancellation
* is cleared (DB + Stripe) so a freshly-activated Team is not left scheduled to
* cancel, including the legacy personal-scoped Team case where the plan is
* unchanged.
*/
async function activateTeamSubscription(
sub: { id: string; cancelAtPeriodEnd?: boolean | null; stripeSubscriptionId: string | null },
targetPlan: string,
{ planChanged }: { planChanged: boolean }
): Promise<void> {
const shouldClearCancellation =
Boolean(sub.cancelAtPeriodEnd) && Boolean(sub.stripeSubscriptionId)
await db.transaction(async (tx) => {
await tx
.update(subscriptionTable)
.set({ plan: targetPlan, cancelAtPeriodEnd: false })
.where(eq(subscriptionTable.id, sub.id))
if (planChanged) {
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS, {
subscriptionId: sub.id,
reason: 'pro-to-team-conversion',
})
}
if (shouldClearCancellation) {
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
stripeSubscriptionId: sub.stripeSubscriptionId as string,
subscriptionId: sub.id,
reason: 'pro-to-team-conversion',
})
}
})
}
/**
* Map a Pro (or legacy) plan to a Team tier, choosing the smallest Team tier
* whose credit allowance is at least the current plan's so an upgrade never
* silently drops credits. Returns `null` when no eligible Team tier exists
* (e.g. a Max owner when the Team Max price is unconfigured) so the caller can
* surface `upgrade-required` instead of downgrading.
*/
function mapToTeamPlanName(plan: string): string | null {
if (isTeam(plan)) return plan
const credits = getPlanTierCredits(plan)
const eligibleTiers = [...CREDIT_TIERS]
.filter((tier) => tier.credits >= credits)
.sort((a, b) => a.credits - b.credits)
for (const tier of eligibleTiers) {
const candidate = buildPlanName('team', tier.credits)
if (getPlanByName(candidate)) {
return candidate
}
}
return null
}
@@ -0,0 +1,108 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockReconcileOrganizationSeats, selectRows, mockFeatureFlags } = vi.hoisted(() => ({
mockReconcileOrganizationSeats: vi.fn(),
selectRows: { value: [] as unknown[] },
mockFeatureFlags: { isBillingEnabled: true },
}))
vi.mock('@sim/db', () => {
const makeChain = () => {
const chain: Record<string, unknown> = {}
chain.from = () => chain
chain.innerJoin = () => chain
chain.where = () => chain
chain.groupBy = () => chain
chain.having = () => chain
chain.orderBy = () => Promise.resolve(selectRows.value)
return chain
}
return { db: { select: () => makeChain() } }
})
vi.mock('@/lib/billing/organizations/seats', () => ({
reconcileOrganizationSeats: mockReconcileOrganizationSeats,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isBillingEnabled() {
return mockFeatureFlags.isBillingEnabled
},
}))
import { reconcileTeamSeatDrift } from '@/lib/billing/organizations/seat-drift'
describe('reconcileTeamSeatDrift', () => {
beforeEach(() => {
vi.clearAllMocks()
selectRows.value = []
mockFeatureFlags.isBillingEnabled = true
mockReconcileOrganizationSeats.mockResolvedValue({ changed: true, previousSeats: 1, seats: 2 })
})
it('reconciles each drifted Team org returned by the query', async () => {
// The SQL WHERE (Team-only) + HAVING (seats != member count) already
// restrict the result to drifted Team orgs; the function reconciles each.
selectRows.value = [{ organizationId: 'org-1' }, { organizationId: 'org-2' }]
const result = await reconcileTeamSeatDrift()
expect(result).toEqual({ drifted: 2, reconciled: 2 })
expect(mockReconcileOrganizationSeats).toHaveBeenCalledTimes(2)
expect(mockReconcileOrganizationSeats).toHaveBeenCalledWith({
organizationId: 'org-1',
reason: 'seat-drift-sweep',
})
expect(mockReconcileOrganizationSeats).toHaveBeenCalledWith({
organizationId: 'org-2',
reason: 'seat-drift-sweep',
})
})
it('counts only reconciles that changed the seat count', async () => {
selectRows.value = [{ organizationId: 'org-a' }, { organizationId: 'org-b' }]
mockReconcileOrganizationSeats
.mockResolvedValueOnce({ changed: true, seats: 2 })
.mockResolvedValueOnce({ changed: false })
const result = await reconcileTeamSeatDrift()
expect(result).toEqual({ drifted: 2, reconciled: 1 })
})
it('continues past a reconcile failure', async () => {
selectRows.value = [{ organizationId: 'org-a' }, { organizationId: 'org-b' }]
mockReconcileOrganizationSeats
.mockRejectedValueOnce(new Error('db error'))
.mockResolvedValueOnce({ changed: true, seats: 3 })
const result = await reconcileTeamSeatDrift()
expect(result).toEqual({ drifted: 2, reconciled: 1 })
expect(mockReconcileOrganizationSeats).toHaveBeenCalledTimes(2)
})
it('no-ops when billing is disabled', async () => {
mockFeatureFlags.isBillingEnabled = false
const result = await reconcileTeamSeatDrift()
expect(result).toEqual({ drifted: 0, reconciled: 0 })
expect(mockReconcileOrganizationSeats).not.toHaveBeenCalled()
})
it('caps reconciles per run while still reporting the full drift count', async () => {
selectRows.value = Array.from({ length: 150 }, (_, i) => ({
organizationId: `org-${i}`,
}))
const result = await reconcileTeamSeatDrift()
expect(result.drifted).toBe(150)
expect(result.reconciled).toBe(100)
expect(mockReconcileOrganizationSeats).toHaveBeenCalledTimes(100)
})
})
@@ -0,0 +1,94 @@
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNotNull, like, or, sql } from 'drizzle-orm'
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
const logger = createLogger('SeatDriftSweep')
/** Max orgs reconciled per sweep run so a mass-drift event can't run away. */
const MAX_RECONCILES_PER_RUN = 100
export interface SeatDriftSweepResult {
/** How many Team orgs were found with a seat/member-count mismatch. */
drifted: number
/** How many of those were reconciled (seat count changed) this run. */
reconciled: number
}
/**
* Periodic backstop that re-aligns Team organizations whose stored seat count
* has drifted from their actual member count — e.g. when a post-join or
* post-removal `reconcileOrganizationSeats` transaction failed before
* committing. Each drifted org is reconciled, which re-enqueues the Stripe
* seat-sync.
*
* This only catches drift where the DB seat count is wrong. The opposite case —
* the DB committed but the Stripe sync dead-lettered — is surfaced via the
* dead-letter report, since the seat counts already match here.
*
* Candidates are sampled in random order so that, when more than
* `MAX_RECONCILES_PER_RUN` orgs drift, a subset that keeps failing can't starve
* the rest — each run reconciles a different random slice until all converge.
* Subscriptions without a Stripe id are excluded since their drift is not
* resolvable here (reconcile can't push to Stripe).
*/
export async function reconcileTeamSeatDrift(): Promise<SeatDriftSweepResult> {
if (!isBillingEnabled) {
return { drifted: 0, reconciled: 0 }
}
/**
* The filter runs entirely in SQL: only Team plans (`team` or `team_*`), with
* a usable status and a Stripe id, whose stored `seats` differs from their
* live member count (`HAVING`). Non-Team orgs are never materialized.
*/
const driftedRows = await db
.select({ organizationId: subscription.referenceId })
.from(subscription)
.innerJoin(member, eq(member.organizationId, subscription.referenceId))
.where(
and(
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES),
isNotNull(subscription.stripeSubscriptionId),
or(eq(subscription.plan, 'team'), like(subscription.plan, 'team\\_%'))
)
)
.groupBy(subscription.referenceId, subscription.plan, subscription.seats)
.having(sql`coalesce(${subscription.seats}, 1) <> count(${member.id})`)
.orderBy(sql`random()`)
const batch = driftedRows.slice(0, MAX_RECONCILES_PER_RUN)
let reconciled = 0
for (const row of batch) {
try {
const result = await reconcileOrganizationSeats({
organizationId: row.organizationId,
reason: 'seat-drift-sweep',
})
if (result.changed) reconciled++
} catch (error) {
logger.error('Failed to reconcile seat drift for organization', {
organizationId: row.organizationId,
error,
})
}
}
if (driftedRows.length > batch.length) {
logger.warn('Seat drift sweep hit its per-run cap; remaining orgs reconcile next run', {
drifted: driftedRows.length,
cap: MAX_RECONCILES_PER_RUN,
})
} else if (driftedRows.length > 0) {
logger.info('Seat drift sweep reconciled organizations', {
drifted: driftedRows.length,
reconciled,
})
}
return { drifted: driftedRows.length, reconciled }
}
@@ -0,0 +1,211 @@
/**
* @vitest-environment node
*/
import { auditMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSyncSubscriptionUsageLimits, enqueueMock, setMock, queryQueue, mockFeatureFlags } =
vi.hoisted(() => ({
mockSyncSubscriptionUsageLimits: vi.fn(),
enqueueMock: vi.fn(),
setMock: vi.fn(),
queryQueue: { value: [] as unknown[][] },
mockFeatureFlags: { isBillingEnabled: true },
}))
vi.mock('@sim/db', () => {
const makeSelectChain = () => {
const chain: Record<string, unknown> = {}
chain.from = () => chain
chain.where = () => chain
chain.for = () => chain
chain.limit = () => Promise.resolve(queryQueue.value.shift() ?? [])
chain.then = (resolve: (rows: unknown[]) => unknown, reject?: (e: unknown) => unknown) =>
Promise.resolve(queryQueue.value.shift() ?? []).then(resolve, reject)
return chain
}
const update = () => ({
set: (values: Record<string, unknown>) => {
setMock(values)
return { where: () => Promise.resolve([]) }
},
})
const txMock = { select: () => makeSelectChain(), update }
const dbMock = {
select: () => makeSelectChain(),
update,
transaction: async (cb: (tx: typeof txMock) => Promise<unknown>) => cb(txMock),
}
return { db: dbMock }
})
vi.mock('@/lib/billing/organization', () => ({
syncSubscriptionUsageLimits: mockSyncSubscriptionUsageLimits,
}))
vi.mock('@/lib/core/outbox/service', () => ({
enqueueOutboxEvent: enqueueMock,
}))
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({
OUTBOX_EVENT_TYPES: {
STRIPE_SYNC_SUBSCRIPTION_SEATS: 'stripe.sync-subscription-seats',
},
}))
vi.mock('@/lib/core/config/env-flags', () => ({
get isBillingEnabled() {
return mockFeatureFlags.isBillingEnabled
},
}))
vi.mock('@sim/audit', () => auditMock)
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
const teamSub = {
id: 'sub-1',
plan: 'team_6000',
status: 'active',
seats: 1,
stripeSubscriptionId: 'stripe_sub',
}
describe('reconcileOrganizationSeats', () => {
beforeEach(() => {
vi.clearAllMocks()
queryQueue.value = []
enqueueMock.mockResolvedValue('evt-1')
mockFeatureFlags.isBillingEnabled = true
})
it('grows seats to the member count and enqueues a Stripe sync', async () => {
queryQueue.value = [[teamSub], [{ value: 2 }]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-accepted-invite',
})
expect(result).toEqual({
changed: true,
previousSeats: 1,
seats: 2,
reason: undefined,
outboxEventId: 'evt-1',
})
expect(setMock).toHaveBeenCalledWith({ seats: 2 })
expect(enqueueMock).toHaveBeenCalledWith(expect.anything(), 'stripe.sync-subscription-seats', {
subscriptionId: 'sub-1',
reason: 'member-accepted-invite',
})
expect(mockSyncSubscriptionUsageLimits).toHaveBeenCalledWith(
expect.objectContaining({ id: 'sub-1', referenceId: 'org-1', seats: 2 })
)
})
it('still records the seat audit when the post-commit usage-limit sync fails', async () => {
queryQueue.value = [[teamSub], [{ value: 2 }]]
mockSyncSubscriptionUsageLimits.mockRejectedValueOnce(new Error('sync unavailable'))
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-accepted-invite',
actorId: 'user-1',
})
expect(result.changed).toBe(true)
expect(setMock).toHaveBeenCalledWith({ seats: 2 })
expect(auditMock.recordAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'user-1',
action: auditMock.AuditAction.ORG_SEAT_PROVISIONED,
resourceId: 'org-1',
})
)
})
it('reduces seats to the member count on removal', async () => {
queryQueue.value = [[{ ...teamSub, seats: 3 }], [{ value: 2 }]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-removed',
})
expect(result.changed).toBe(true)
expect(result.seats).toBe(2)
expect(setMock).toHaveBeenCalledWith({ seats: 2 })
expect(enqueueMock).toHaveBeenCalled()
})
it('is a no-op when seats already match the member count', async () => {
queryQueue.value = [[{ ...teamSub, seats: 2 }], [{ value: 2 }]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-removed',
})
expect(result).toEqual({
changed: false,
previousSeats: 2,
seats: 2,
reason: undefined,
outboxEventId: undefined,
})
expect(setMock).not.toHaveBeenCalled()
expect(enqueueMock).not.toHaveBeenCalled()
expect(mockSyncSubscriptionUsageLimits).not.toHaveBeenCalled()
})
it('never drops below one seat', async () => {
queryQueue.value = [[{ ...teamSub, seats: 3 }], [{ value: 0 }]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-removed',
})
expect(result.seats).toBe(1)
expect(setMock).toHaveBeenCalledWith({ seats: 1 })
})
it('skips non-Team subscriptions', async () => {
queryQueue.value = [[{ ...teamSub, plan: 'pro_6000' }]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-accepted-invite',
})
expect(result.changed).toBe(false)
expect(result.reason).toMatch(/Team/)
expect(setMock).not.toHaveBeenCalled()
expect(enqueueMock).not.toHaveBeenCalled()
})
it('skips when the organization has no usable subscription', async () => {
queryQueue.value = [[]]
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-accepted-invite',
})
expect(result).toEqual({ changed: false, reason: 'No active subscription found' })
expect(enqueueMock).not.toHaveBeenCalled()
})
it('no-ops when billing is disabled', async () => {
mockFeatureFlags.isBillingEnabled = false
const result = await reconcileOrganizationSeats({
organizationId: 'org-1',
reason: 'member-accepted-invite',
})
expect(result).toEqual({ changed: false, reason: 'Billing is not enabled' })
expect(enqueueMock).not.toHaveBeenCalled()
})
})
+196
View File
@@ -0,0 +1,196 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, inArray } from 'drizzle-orm'
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
import { isTeam } from '@/lib/billing/plan-helpers'
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('OrganizationSeats')
export interface ReconcileOrganizationSeatsResult {
changed: boolean
previousSeats?: number
seats?: number
reason?: string
outboxEventId?: string
}
interface ReconcileOrganizationSeatsParams {
organizationId: string
reason: string
/**
* Real `user.id` of the actor whose action triggered this reconcile, used to
* attribute the seat-change audit log and analytics event. Omit for system
* reconciles (e.g. the seat-drift sweep) that have no acting user; the audit
* is then skipped and analytics fall back to the organization id.
*/
actorId?: string
}
/**
* Reconcile a Team organization's seat count to its current member count and
* enqueue an outbox event to push the change to Stripe. This is the single
* seat-accounting path for both joins and removals: paid seats always equal
* the number of organization members.
*
* The DB write and the outbox enqueue commit atomically; the actual Stripe
* charge/credit (proration via `always_invoice`) happens asynchronously in the
* seat-sync handler. A failed charge surfaces through Stripe dunning and blocks
* the org via the existing billing-blocked system rather than under this lock.
* Concurrent joins/removals serialize on the subscription row's `FOR UPDATE`
* lock and each reconciles to the live member count, so the final seat count is
* always correct regardless of interleaving.
*/
export async function reconcileOrganizationSeats({
organizationId,
reason,
actorId,
}: ReconcileOrganizationSeatsParams): Promise<ReconcileOrganizationSeatsResult> {
if (!isBillingEnabled) {
return { changed: false, reason: 'Billing is not enabled' }
}
type ReconcileOutcome =
| { kind: 'skip'; reason: string }
| { kind: 'noop'; seats: number }
| {
kind: 'changed'
previousSeats: number
seats: number
outboxEventId: string
sync: { id: string; plan: string; status: string | null }
}
const outcome = await db.transaction<ReconcileOutcome>(async (tx) => {
const [orgSubscription] = await tx
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.for('update')
.limit(1)
if (!orgSubscription) {
return { kind: 'skip', reason: 'No active subscription found' }
}
if (!isTeam(orgSubscription.plan)) {
return { kind: 'skip', reason: 'Seat changes are only available for Team plans' }
}
if (!orgSubscription.stripeSubscriptionId) {
return { kind: 'skip', reason: 'No Stripe subscription found for this organization' }
}
const [memberCountRow] = await tx
.select({ value: count() })
.from(member)
.where(eq(member.organizationId, organizationId))
const targetSeats = Math.max(1, memberCountRow?.value ?? 1)
const currentSeats = orgSubscription.seats ?? 1
if (targetSeats === currentSeats) {
return { kind: 'noop', seats: currentSeats }
}
await tx
.update(subscription)
.set({ seats: targetSeats })
.where(eq(subscription.id, orgSubscription.id))
const outboxEventId = await enqueueOutboxEvent(
tx,
OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS,
{
subscriptionId: orgSubscription.id,
reason,
}
)
return {
kind: 'changed',
previousSeats: currentSeats,
seats: targetSeats,
outboxEventId,
sync: {
id: orgSubscription.id,
plan: orgSubscription.plan,
status: orgSubscription.status,
},
}
})
if (outcome.kind === 'skip') {
return { changed: false, reason: outcome.reason }
}
if (outcome.kind === 'noop') {
return { changed: false, previousSeats: outcome.seats, seats: outcome.seats }
}
try {
await syncSubscriptionUsageLimits({
id: outcome.sync.id,
plan: outcome.sync.plan,
referenceId: organizationId,
status: outcome.sync.status,
seats: outcome.seats,
})
} catch (error) {
// Seats already committed in the transaction above; a failure here must not
// suppress the audit/analytics events below for a change that already happened.
logger.error('Failed to sync usage limits after seat reconciliation', {
organizationId,
previousSeats: outcome.previousSeats,
seats: outcome.seats,
error,
})
}
logger.info('Reconciled organization seats to member count', {
organizationId,
previousSeats: outcome.previousSeats,
seats: outcome.seats,
reason,
outboxEventId: outcome.outboxEventId,
})
const increased = outcome.seats > outcome.previousSeats
if (actorId) {
recordAudit({
workspaceId: null,
actorId,
action: increased ? AuditAction.ORG_SEAT_PROVISIONED : AuditAction.ORG_SEAT_DEPROVISIONED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: `${increased ? 'Provisioned' : 'Deprovisioned'} organization seats: ${outcome.previousSeats}${outcome.seats}`,
metadata: { previousSeats: outcome.previousSeats, seats: outcome.seats, reason },
})
}
captureServerEvent(
actorId ?? organizationId,
increased ? 'seats_provisioned' : 'seats_deprovisioned',
{
organization_id: organizationId,
previous_seats: outcome.previousSeats,
seats: outcome.seats,
reason,
},
{ groups: { organization: organizationId } }
)
return {
changed: true,
previousSeats: outcome.previousSeats,
seats: outcome.seats,
outboxEventId: outcome.outboxEventId,
}
}