d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
197 lines
6.3 KiB
TypeScript
197 lines
6.3 KiB
TypeScript
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,
|
|
}
|
|
}
|