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
577 lines
18 KiB
TypeScript
577 lines
18 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { member, organization, subscription, user } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
|
import { and, eq, inArray, sql } from 'drizzle-orm'
|
|
import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access'
|
|
import {
|
|
getHighestPriorityPersonalSubscription,
|
|
getHighestPrioritySubscription,
|
|
} from '@/lib/billing/core/plan'
|
|
import {
|
|
getPlanTierCredits,
|
|
isEnterprise as isPlanEnterprise,
|
|
isPro as isPlanPro,
|
|
isTeam as isPlanTeam,
|
|
} from '@/lib/billing/plan-helpers'
|
|
import {
|
|
checkEnterprisePlan,
|
|
checkProPlan,
|
|
checkTeamPlan,
|
|
ENTITLED_SUBSCRIPTION_STATUSES,
|
|
hasUsableSubscriptionAccess,
|
|
USABLE_SUBSCRIPTION_STATUSES,
|
|
} from '@/lib/billing/subscriptions/utils'
|
|
import {
|
|
isAccessControlEnabled,
|
|
isBillingEnabled,
|
|
isHosted,
|
|
isInboxEnabled,
|
|
isSsoEnabled,
|
|
} from '@/lib/core/config/env-flags'
|
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
|
|
|
const logger = createLogger('SubscriptionCore')
|
|
|
|
export { getHighestPriorityPersonalSubscription, getHighestPrioritySubscription }
|
|
|
|
export interface SubscriptionMetadata {
|
|
billingInterval?: 'month' | 'year'
|
|
[key: string]: unknown
|
|
}
|
|
|
|
export interface HasPaidSubscriptionOptions {
|
|
onError?: 'assume-active' | 'throw'
|
|
}
|
|
|
|
/**
|
|
* Extract the billing interval from subscription metadata, defaulting to 'month'.
|
|
*/
|
|
export function getBillingInterval(
|
|
metadata: SubscriptionMetadata | null | undefined
|
|
): 'month' | 'year' {
|
|
return metadata?.billingInterval === 'year' ? 'year' : 'month'
|
|
}
|
|
|
|
/**
|
|
* Resolves a subscription's effective billing interval. Prefers the Stripe-synced
|
|
* `billingInterval` column — the only source populated on enterprise/manual
|
|
* subscriptions, which skip the checkout flow that writes the metadata value — and
|
|
* falls back to `metadata.billingInterval` (the column is often null on
|
|
* checkout-created subs), defaulting to monthly. Where both are set they agree.
|
|
*/
|
|
export function resolveBillingInterval(
|
|
sub: { billingInterval?: string | null; metadata?: unknown } | null | undefined
|
|
): 'month' | 'year' {
|
|
const column = sub?.billingInterval
|
|
if (column === 'year' || column === 'month') return column
|
|
return getBillingInterval((sub?.metadata ?? null) as SubscriptionMetadata | null)
|
|
}
|
|
|
|
/**
|
|
* Merge a `billingInterval` value into a subscription's metadata JSON column.
|
|
*/
|
|
export async function writeBillingInterval(
|
|
subscriptionId: string,
|
|
interval: 'month' | 'year'
|
|
): Promise<void> {
|
|
const patch = JSON.stringify({ billingInterval: interval })
|
|
await db
|
|
.update(subscription)
|
|
.set({
|
|
metadata: sql`(COALESCE(metadata::jsonb, '{}'::jsonb) || ${patch}::jsonb)::json`,
|
|
})
|
|
.where(eq(subscription.id, subscriptionId))
|
|
}
|
|
|
|
/**
|
|
* Sync the subscription's `plan` column to match Stripe. Closes a gap
|
|
* where plan changes (Pro → Team upgrades, tier swaps) updated price,
|
|
* seats, and referenceId at Stripe but left the DB plan stale. Returns
|
|
* `true` if a write was issued, `false` if no change was needed.
|
|
*/
|
|
export async function syncSubscriptionPlan(
|
|
subscriptionId: string,
|
|
currentPlan: string | null,
|
|
planFromStripe: string | null
|
|
): Promise<boolean> {
|
|
if (!planFromStripe) return false
|
|
if (currentPlan === planFromStripe) return false
|
|
|
|
await db
|
|
.update(subscription)
|
|
.set({ plan: planFromStripe })
|
|
.where(eq(subscription.id, subscriptionId))
|
|
|
|
logger.info('Synced subscription plan name from Stripe', {
|
|
subscriptionId,
|
|
previousPlan: currentPlan,
|
|
newPlan: planFromStripe,
|
|
})
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Get the organization's subscription row when its status is one of
|
|
* `USABLE_SUBSCRIPTION_STATUSES` (product access — stricter than
|
|
* `ENTITLED_SUBSCRIPTION_STATUSES` which also includes `past_due`).
|
|
* Use this for feature-gating ("can this org use the product right
|
|
* now"). Use `getOrganizationSubscription` (from `core/billing.ts`)
|
|
* when you need the billing-side entitlement row that includes
|
|
* past-due subscriptions. Returns `null` when there is no usable sub.
|
|
*/
|
|
export async function getOrganizationSubscriptionUsable(organizationId: string) {
|
|
try {
|
|
const [orgSub] = await db
|
|
.select()
|
|
.from(subscription)
|
|
.where(
|
|
and(
|
|
eq(subscription.referenceId, organizationId),
|
|
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
return orgSub ?? null
|
|
} catch (error) {
|
|
logger.error('Error getting usable organization subscription', { error, organizationId })
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a referenceId (user ID or org ID) has a paid subscription row.
|
|
* Used for duplicate subscription prevention and transfer safety.
|
|
*
|
|
* Fails closed by default: returns true on error to prevent duplicate creation.
|
|
*/
|
|
export async function hasPaidSubscription(
|
|
referenceId: string,
|
|
options: HasPaidSubscriptionOptions = {}
|
|
): Promise<boolean> {
|
|
const { onError = 'assume-active' } = options
|
|
|
|
try {
|
|
const [activeSub] = await db
|
|
.select({ id: subscription.id })
|
|
.from(subscription)
|
|
.where(
|
|
and(
|
|
eq(subscription.referenceId, referenceId),
|
|
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
return !!activeSub
|
|
} catch (error) {
|
|
logger.error('Error checking active subscription', { error, referenceId })
|
|
|
|
if (onError === 'throw') {
|
|
throw error
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
export async function getOrganizationIdForSubscriptionReference(
|
|
referenceId: string
|
|
): Promise<string | null> {
|
|
const [referencedOrganization] = await db
|
|
.select({ id: organization.id })
|
|
.from(organization)
|
|
.where(eq(organization.id, referenceId))
|
|
.limit(1)
|
|
|
|
if (referencedOrganization) {
|
|
return referencedOrganization.id
|
|
}
|
|
|
|
const [memberRecord] = await db
|
|
.select({
|
|
organizationId: member.organizationId,
|
|
role: member.role,
|
|
})
|
|
.from(member)
|
|
.where(eq(member.userId, referenceId))
|
|
.limit(1)
|
|
|
|
if (memberRecord && isOrgAdminRole(memberRecord.role)) {
|
|
return memberRecord.organizationId
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check if user is on Pro plan (direct or via organization)
|
|
*/
|
|
export async function isProPlan(userId: string): Promise<boolean> {
|
|
try {
|
|
if (!isBillingEnabled) {
|
|
return true
|
|
}
|
|
|
|
const subscription = await getHighestPrioritySubscription(userId)
|
|
const isPro =
|
|
subscription &&
|
|
(checkProPlan(subscription) ||
|
|
checkTeamPlan(subscription) ||
|
|
checkEnterprisePlan(subscription))
|
|
|
|
if (isPro) {
|
|
logger.info('User has pro-level plan', { userId, plan: subscription.plan })
|
|
}
|
|
|
|
return !!isPro
|
|
} catch (error) {
|
|
logger.error('Error checking pro plan status', { error, userId })
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user is on Team plan (direct or via organization)
|
|
*/
|
|
export async function isTeamPlan(userId: string): Promise<boolean> {
|
|
try {
|
|
if (!isBillingEnabled) {
|
|
return true
|
|
}
|
|
|
|
const subscription = await getHighestPrioritySubscription(userId)
|
|
const isTeam =
|
|
subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription))
|
|
|
|
if (isTeam) {
|
|
logger.info('User has team-level plan', { userId, plan: subscription.plan })
|
|
}
|
|
|
|
return !!isTeam
|
|
} catch (error) {
|
|
logger.error('Error checking team plan status', { error, userId })
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user is on Enterprise plan (direct or via organization)
|
|
*/
|
|
export async function isEnterprisePlan(userId: string): Promise<boolean> {
|
|
try {
|
|
if (!isBillingEnabled) {
|
|
return true
|
|
}
|
|
|
|
const subscription = await getHighestPrioritySubscription(userId)
|
|
const isEnterprise = subscription && checkEnterprisePlan(subscription)
|
|
|
|
if (isEnterprise) {
|
|
logger.info('User has enterprise plan', { userId, plan: subscription.plan })
|
|
}
|
|
|
|
return !!isEnterprise
|
|
} catch (error) {
|
|
logger.error('Error checking enterprise plan status', { error, userId })
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user is an admin or owner of an enterprise organization
|
|
* Returns true if:
|
|
* - User is a member of an enterprise organization AND
|
|
* - User's role in that organization is 'owner' or 'admin'
|
|
*
|
|
* In non-production environments, returns true for convenience.
|
|
*/
|
|
export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boolean> {
|
|
try {
|
|
if (!isBillingEnabled) {
|
|
return true
|
|
}
|
|
|
|
const [memberRecord] = await db
|
|
.select({
|
|
organizationId: member.organizationId,
|
|
role: member.role,
|
|
})
|
|
.from(member)
|
|
.where(eq(member.userId, userId))
|
|
.limit(1)
|
|
|
|
if (!memberRecord) {
|
|
return false
|
|
}
|
|
|
|
if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') {
|
|
return false
|
|
}
|
|
|
|
const billingStatus = await getEffectiveBillingStatus(userId)
|
|
if (billingStatus.billingBlocked) {
|
|
return false
|
|
}
|
|
|
|
const orgSub = await getOrganizationSubscriptionUsable(memberRecord.organizationId)
|
|
|
|
const isEnterprise = orgSub && checkEnterprisePlan(orgSub)
|
|
|
|
if (isEnterprise) {
|
|
logger.info('User is enterprise org admin/owner', {
|
|
userId,
|
|
organizationId: memberRecord.organizationId,
|
|
role: memberRecord.role,
|
|
})
|
|
}
|
|
|
|
return !!isEnterprise
|
|
} catch (error) {
|
|
logger.error('Error checking enterprise org admin/owner status', { error, userId })
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if an organization has an enterprise plan
|
|
* Used for Access Control (Permission Groups) feature gating
|
|
*/
|
|
export async function isOrganizationOnEnterprisePlan(organizationId: string): Promise<boolean> {
|
|
try {
|
|
if (!isBillingEnabled) {
|
|
return true
|
|
}
|
|
|
|
if (isAccessControlEnabled && !isHosted) {
|
|
return true
|
|
}
|
|
|
|
if (await isOrganizationBillingBlocked(organizationId)) {
|
|
return false
|
|
}
|
|
|
|
const orgSub = await getOrganizationSubscriptionUsable(organizationId)
|
|
|
|
return !!orgSub && checkEnterprisePlan(orgSub)
|
|
} catch (error) {
|
|
logger.error('Error checking organization enterprise plan status', { error, organizationId })
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user has access to SSO feature
|
|
* Returns true if:
|
|
* - SSO_ENABLED env var is set (self-hosted override), OR
|
|
* - User is admin/owner of an enterprise organization
|
|
*
|
|
* In non-production environments, returns true for convenience.
|
|
*/
|
|
export async function hasSSOAccess(userId: string): Promise<boolean> {
|
|
try {
|
|
if (isSsoEnabled && !isHosted) {
|
|
return true
|
|
}
|
|
|
|
return isEnterpriseOrgAdminOrOwner(userId)
|
|
} catch (error) {
|
|
logger.error('Error checking SSO access', { error, userId })
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check whether a workspace is entitled to the Access Control (Permission Groups)
|
|
* feature. Entitlement follows the workspace's `billedAccountUserId`:
|
|
* - self-hosted override honored via ACCESS_CONTROL_ENABLED, OR
|
|
* - billing disabled, OR
|
|
* - the workspace belongs to an enterprise-plan organization (org-mode), OR
|
|
* - the billed user has an individual enterprise subscription (personal workspace).
|
|
*/
|
|
export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise<boolean> {
|
|
try {
|
|
if (!isBillingEnabled) return true
|
|
if (isAccessControlEnabled && !isHosted) return true
|
|
|
|
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
|
|
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
|
|
if (!ws) return false
|
|
|
|
if (ws.organizationId) {
|
|
return isOrganizationOnEnterprisePlan(ws.organizationId)
|
|
}
|
|
|
|
const billedSub = await getHighestPrioritySubscription(ws.billedAccountUserId)
|
|
return !!billedSub && checkEnterprisePlan(billedSub)
|
|
} catch (error) {
|
|
logger.error('Error checking workspace enterprise plan status', { error, workspaceId })
|
|
return false
|
|
}
|
|
}
|
|
|
|
const MAX_PLAN_CREDITS = 25000
|
|
|
|
/**
|
|
* Whether a plan tier entitles the inbox (Sim Mailer) feature: a Max tier
|
|
* (credits >= 25000, covering `pro_25000` and `team_25000`) or any enterprise
|
|
* plan. Subscription status (usable vs entitled) is gated by callers before this
|
|
* runs — the predicate is tier-only.
|
|
*/
|
|
function isInboxEntitledPlan(plan: string): boolean {
|
|
return getPlanTierCredits(plan) >= MAX_PLAN_CREDITS || isPlanEnterprise(plan)
|
|
}
|
|
|
|
/**
|
|
* Check whether a workspace is entitled to the inbox (Sim Mailer) feature.
|
|
* Entitlement follows the workspace's billing entity — not the acting user — so
|
|
* any workspace admin (including an external member) can manage the inbox when
|
|
* the workspace's organization, or its billed account for personal workspaces,
|
|
* is on a Max or enterprise plan.
|
|
*
|
|
* Returns true if:
|
|
* - INBOX_ENABLED env var is set (self-hosted override), OR
|
|
* - billing is disabled, OR
|
|
* - the workspace belongs to an organization on a Max/enterprise plan (org-mode), OR
|
|
* - the billed user has an individual Max/enterprise subscription (personal workspace).
|
|
*/
|
|
export async function hasWorkspaceInboxAccess(workspaceId: string): Promise<boolean> {
|
|
try {
|
|
if (isInboxEnabled) return true
|
|
if (!isBillingEnabled) return true
|
|
|
|
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
|
|
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
|
|
if (!ws) return false
|
|
|
|
if (ws.organizationId) {
|
|
if (await isOrganizationBillingBlocked(ws.organizationId)) return false
|
|
const orgSub = await getOrganizationSubscriptionUsable(ws.organizationId)
|
|
return !!orgSub && isInboxEntitledPlan(orgSub.plan)
|
|
}
|
|
|
|
const [billedSub, billingStatus] = await Promise.all([
|
|
getHighestPrioritySubscription(ws.billedAccountUserId),
|
|
getEffectiveBillingStatus(ws.billedAccountUserId),
|
|
])
|
|
if (!billedSub) return false
|
|
if (!hasUsableSubscriptionAccess(billedSub.status, billingStatus.billingBlocked)) return false
|
|
return isInboxEntitledPlan(billedSub.plan)
|
|
} catch (error) {
|
|
logger.error('Error checking workspace inbox access', { error, workspaceId })
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a workspace should RETAIN its provisioned inbox (Sim Mailer)
|
|
* infrastructure. Unlike {@link hasWorkspaceInboxAccess}, which gates active use
|
|
* on a *usable* (active) subscription, this uses the broader *entitled* status
|
|
* set (active OR `past_due`) so a transient payment failure never triggers the
|
|
* destructive teardown of a paying customer's inbox.
|
|
*
|
|
* Reconciliation should delete AgentMail resources only when this returns
|
|
* `false` — i.e. the plan is genuinely terminal (canceled, downgraded off
|
|
* Max/Enterprise, or gone). Fails open (returns `true`) on any error or
|
|
* ambiguity: never tear down on uncertainty.
|
|
*/
|
|
export async function hasWorkspaceInboxGraceAccess(workspaceId: string): Promise<boolean> {
|
|
try {
|
|
if (isInboxEnabled) return true
|
|
if (!isBillingEnabled) return true
|
|
|
|
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
|
|
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
|
|
if (!ws) return true
|
|
|
|
if (ws.organizationId) {
|
|
const { getOrganizationSubscription } = await import('@/lib/billing/core/billing')
|
|
const orgSub = await getOrganizationSubscription(ws.organizationId)
|
|
return !!orgSub && isInboxEntitledPlan(orgSub.plan)
|
|
}
|
|
|
|
const billedSub = await getHighestPrioritySubscription(ws.billedAccountUserId)
|
|
return !!billedSub && isInboxEntitledPlan(billedSub.plan)
|
|
} catch (error) {
|
|
logger.error('Error checking workspace inbox grace access', { error, workspaceId })
|
|
return true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user has access to live sync (every 5 minutes) for KB connectors
|
|
* Returns true if:
|
|
* - Self-hosted deployment, OR
|
|
* - User has a Max plan (credits >= 25000) or enterprise plan
|
|
*/
|
|
export async function hasLiveSyncAccess(userId: string): Promise<boolean> {
|
|
try {
|
|
if (!isHosted) {
|
|
return true
|
|
}
|
|
const [sub, billingStatus] = await Promise.all([
|
|
getHighestPrioritySubscription(userId),
|
|
getEffectiveBillingStatus(userId),
|
|
])
|
|
if (!sub) return false
|
|
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) return false
|
|
return getPlanTierCredits(sub.plan) >= 25000 || checkEnterprisePlan(sub)
|
|
} catch (error) {
|
|
logger.error('Error checking live sync access', { error, userId })
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send welcome email for Pro and Team plan subscriptions
|
|
*/
|
|
export async function sendPlanWelcomeEmail(subscription: any): Promise<void> {
|
|
try {
|
|
const subPlan = subscription.plan
|
|
if (isPlanPro(subPlan) || isPlanTeam(subPlan)) {
|
|
const userId = subscription.referenceId
|
|
const users = await db
|
|
.select({ email: user.email, name: user.name })
|
|
.from(user)
|
|
.where(eq(user.id, userId))
|
|
.limit(1)
|
|
|
|
if (users.length > 0 && users[0].email) {
|
|
const { getEmailSubject, renderPlanWelcomeEmail } = await import('@/components/emails')
|
|
const { sendEmail } = await import('@/lib/messaging/email/mailer')
|
|
|
|
const baseUrl = getBaseUrl()
|
|
const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers')
|
|
const html = await renderPlanWelcomeEmail({
|
|
planName: getDisplayPlanName(subPlan),
|
|
userName: users[0].name || undefined,
|
|
loginLink: `${baseUrl}/login`,
|
|
})
|
|
|
|
const displayName = getDisplayPlanName(subPlan)
|
|
await sendEmail({
|
|
to: users[0].email,
|
|
subject: `Your ${displayName} plan is now active on ${(await import('@/ee/whitelabeling')).getBrandConfig().name}`,
|
|
html,
|
|
emailType: 'updates',
|
|
})
|
|
|
|
logger.info('Plan welcome email sent successfully', {
|
|
userId,
|
|
email: users[0].email,
|
|
plan: subPlan,
|
|
})
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to send plan welcome email', {
|
|
error,
|
|
subscriptionId: subscription.id,
|
|
plan: subscription.plan,
|
|
})
|
|
throw error
|
|
}
|
|
}
|