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