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
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user