d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
264 lines
8.6 KiB
TypeScript
264 lines
8.6 KiB
TypeScript
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
|
import { db } from '@sim/db'
|
|
import { organization, subscription, user } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { eq } from 'drizzle-orm'
|
|
import type Stripe from 'stripe'
|
|
import { getEmailSubject, renderEnterpriseSubscriptionEmail } from '@/components/emails'
|
|
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
|
|
import { sendEmail } from '@/lib/messaging/email/mailer'
|
|
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
|
import { captureServerEvent } from '@/lib/posthog/server'
|
|
import { parseEnterpriseSubscriptionMetadata } from '../types'
|
|
|
|
const logger = createLogger('BillingEnterprise')
|
|
|
|
export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
|
|
const stripeSubscription = event.data.object as Stripe.Subscription
|
|
|
|
const metaPlan = (stripeSubscription.metadata?.plan as string | undefined)?.toLowerCase() || ''
|
|
|
|
if (metaPlan !== 'enterprise') {
|
|
logger.info('[subscription.created] Skipping non-enterprise subscription', {
|
|
subscriptionId: stripeSubscription.id,
|
|
plan: metaPlan || 'unknown',
|
|
})
|
|
return
|
|
}
|
|
|
|
const stripeCustomerId = stripeSubscription.customer as string
|
|
|
|
if (!stripeCustomerId) {
|
|
logger.error('[subscription.created] Missing Stripe customer ID', {
|
|
subscriptionId: stripeSubscription.id,
|
|
})
|
|
throw new Error('Missing Stripe customer ID on subscription')
|
|
}
|
|
|
|
const metadata = stripeSubscription.metadata || {}
|
|
|
|
const referenceId =
|
|
typeof metadata.referenceId === 'string' && metadata.referenceId.length > 0
|
|
? metadata.referenceId
|
|
: null
|
|
|
|
if (!referenceId) {
|
|
logger.error('[subscription.created] Unable to resolve referenceId', {
|
|
subscriptionId: stripeSubscription.id,
|
|
stripeCustomerId,
|
|
})
|
|
throw new Error('Unable to resolve referenceId for subscription')
|
|
}
|
|
|
|
const enterpriseMetadata = parseEnterpriseSubscriptionMetadata(metadata)
|
|
if (!enterpriseMetadata) {
|
|
logger.error('[subscription.created] Invalid enterprise metadata shape', {
|
|
subscriptionId: stripeSubscription.id,
|
|
metadata,
|
|
})
|
|
throw new Error('Invalid enterprise metadata for subscription')
|
|
}
|
|
|
|
const { seats, monthlyPrice } = enterpriseMetadata
|
|
|
|
// Get the first subscription item which contains the period information
|
|
const referenceItem = stripeSubscription.items?.data?.[0]
|
|
|
|
const subscriptionRow = {
|
|
id: generateId(),
|
|
plan: 'enterprise',
|
|
referenceId,
|
|
stripeCustomerId,
|
|
stripeSubscriptionId: stripeSubscription.id,
|
|
status: stripeSubscription.status || null,
|
|
periodStart: referenceItem?.current_period_start
|
|
? new Date(referenceItem.current_period_start * 1000)
|
|
: null,
|
|
periodEnd: referenceItem?.current_period_end
|
|
? new Date(referenceItem.current_period_end * 1000)
|
|
: null,
|
|
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null,
|
|
seats: 1, // Enterprise uses metadata.seats for actual seat count, column is always 1
|
|
trialStart: stripeSubscription.trial_start
|
|
? new Date(stripeSubscription.trial_start * 1000)
|
|
: null,
|
|
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
|
|
metadata: metadata as Record<string, unknown>,
|
|
}
|
|
|
|
const existing = await db
|
|
.select({ id: subscription.id })
|
|
.from(subscription)
|
|
.where(eq(subscription.stripeSubscriptionId, stripeSubscription.id))
|
|
.limit(1)
|
|
|
|
if (existing.length > 0) {
|
|
await db
|
|
.update(subscription)
|
|
.set({
|
|
plan: subscriptionRow.plan,
|
|
referenceId: subscriptionRow.referenceId,
|
|
stripeCustomerId: subscriptionRow.stripeCustomerId,
|
|
status: subscriptionRow.status,
|
|
periodStart: subscriptionRow.periodStart,
|
|
periodEnd: subscriptionRow.periodEnd,
|
|
cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd,
|
|
seats: 1, // Enterprise uses metadata.seats for actual seat count, column is always 1
|
|
trialStart: subscriptionRow.trialStart,
|
|
trialEnd: subscriptionRow.trialEnd,
|
|
metadata: subscriptionRow.metadata,
|
|
})
|
|
.where(eq(subscription.stripeSubscriptionId, stripeSubscription.id))
|
|
} else {
|
|
await db.insert(subscription).values(subscriptionRow)
|
|
}
|
|
|
|
// Update the organization's usage limit to match the monthly price
|
|
// The referenceId for enterprise plans is the organization ID
|
|
try {
|
|
await db
|
|
.update(organization)
|
|
.set({
|
|
orgUsageLimit: monthlyPrice.toFixed(2),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(organization.id, referenceId))
|
|
|
|
logger.info('[subscription.created] Updated organization usage limit', {
|
|
organizationId: referenceId,
|
|
usageLimit: monthlyPrice,
|
|
})
|
|
} catch (error) {
|
|
logger.error('[subscription.created] Failed to update organization usage limit', {
|
|
organizationId: referenceId,
|
|
usageLimit: monthlyPrice,
|
|
error,
|
|
})
|
|
// Don't throw - the subscription was created successfully, just log the error
|
|
}
|
|
|
|
logger.info('[subscription.created] Upserted enterprise subscription', {
|
|
subscriptionId: subscriptionRow.id,
|
|
referenceId: subscriptionRow.referenceId,
|
|
plan: subscriptionRow.plan,
|
|
status: subscriptionRow.status,
|
|
monthlyPrice,
|
|
seats,
|
|
note: 'Seats from metadata, Stripe quantity set to 1',
|
|
})
|
|
|
|
let actorId = referenceId
|
|
try {
|
|
const [provisioningUser] = await db
|
|
.select({ id: user.id })
|
|
.from(user)
|
|
.where(eq(user.stripeCustomerId, stripeCustomerId))
|
|
.limit(1)
|
|
actorId = provisioningUser?.id ?? referenceId
|
|
} catch (error) {
|
|
logger.warn('Failed to resolve enterprise provisioning actor; falling back to reference id', {
|
|
referenceId,
|
|
error,
|
|
})
|
|
}
|
|
|
|
// Dedupe against Stripe redelivery — the upsert above is idempotent, but
|
|
// recordAudit/captureServerEvent are pure appends and would double-record.
|
|
await stripeWebhookIdempotency.executeWithIdempotency(
|
|
'enterprise-subscription-provisioned',
|
|
event.id,
|
|
async () => {
|
|
recordAudit({
|
|
actorId,
|
|
action: AuditAction.ENTERPRISE_SUBSCRIPTION_PROVISIONED,
|
|
resourceType: AuditResourceType.SUBSCRIPTION,
|
|
resourceId: subscriptionRow.id,
|
|
description: `Enterprise subscription provisioned for organization ${referenceId} (${seats} seats)`,
|
|
metadata: {
|
|
organizationId: referenceId,
|
|
stripeCustomerId,
|
|
stripeSubscriptionId: stripeSubscription.id,
|
|
seats,
|
|
monthlyPrice,
|
|
currency: 'usd',
|
|
},
|
|
})
|
|
captureServerEvent(actorId, 'enterprise_subscription_created', {
|
|
reference_id: referenceId,
|
|
seats,
|
|
monthly_price: monthlyPrice,
|
|
currency: 'usd',
|
|
})
|
|
}
|
|
)
|
|
|
|
try {
|
|
const userDetails = await db
|
|
.select({
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
})
|
|
.from(user)
|
|
.where(eq(user.stripeCustomerId, stripeCustomerId))
|
|
.limit(1)
|
|
|
|
const orgDetails = await db
|
|
.select({
|
|
id: organization.id,
|
|
name: organization.name,
|
|
})
|
|
.from(organization)
|
|
.where(eq(organization.id, referenceId))
|
|
.limit(1)
|
|
|
|
if (userDetails.length > 0 && orgDetails.length > 0) {
|
|
const user = userDetails[0]
|
|
const org = orgDetails[0]
|
|
|
|
const html = await renderEnterpriseSubscriptionEmail(user.name || user.email)
|
|
|
|
const emailResult = await sendEmail({
|
|
to: user.email,
|
|
subject: getEmailSubject('enterprise-subscription'),
|
|
html,
|
|
from: getFromEmailAddress(),
|
|
emailType: 'transactional',
|
|
})
|
|
|
|
if (emailResult.success) {
|
|
logger.info('[subscription.created] Enterprise subscription email sent successfully', {
|
|
userId: user.id,
|
|
email: user.email,
|
|
organizationId: org.id,
|
|
subscriptionId: subscriptionRow.id,
|
|
})
|
|
} else {
|
|
logger.warn('[subscription.created] Failed to send enterprise subscription email', {
|
|
userId: user.id,
|
|
email: user.email,
|
|
error: emailResult.message,
|
|
})
|
|
}
|
|
} else {
|
|
logger.warn(
|
|
'[subscription.created] Could not find user or organization for email notification',
|
|
{
|
|
userFound: userDetails.length > 0,
|
|
orgFound: orgDetails.length > 0,
|
|
stripeCustomerId,
|
|
referenceId,
|
|
}
|
|
)
|
|
}
|
|
} catch (emailError) {
|
|
logger.error('[subscription.created] Error sending enterprise subscription email', {
|
|
error: emailError,
|
|
stripeCustomerId,
|
|
referenceId,
|
|
subscriptionId: subscriptionRow.id,
|
|
})
|
|
}
|
|
}
|