Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,10 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const activeOrgId = activeOrganization?.id

useEffect(() => {
if (subscription.isTeam && activeOrgId) {
if ((subscription.isTeam || subscription.isEnterprise) && activeOrgId) {
loadOrganizationBillingData(activeOrgId)
}
}, [activeOrgId, subscription.isTeam, loadOrganizationBillingData])
}, [activeOrgId, subscription.isTeam, subscription.isEnterprise, loadOrganizationBillingData])

// Auto-clear upgrade error
useEffect(() => {
Expand Down Expand Up @@ -349,22 +349,39 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
badgeText={badgeText}
onBadgeClick={handleBadgeClick}
seatsText={
permissions.canManageTeam
permissions.canManageTeam || subscription.isEnterprise
? `${organizationBillingData?.totalSeats || subscription.seats || 1} seats`
: undefined
}
current={usage.current}
current={
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.totalCurrentUsage || 0
: usage.current
}
limit={
!subscription.isFree &&
(permissions.canEditUsageLimit ||
permissions.showTeamMemberView ||
subscription.isEnterprise)
? usage.current // placeholder; rightContent will render UsageLimit
: usage.limit
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.totalUsageLimit ||
organizationBillingData?.minimumBillingAmount ||
0
: !subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
? usage.current // placeholder; rightContent will render UsageLimit
: usage.limit
}
isBlocked={Boolean(subscriptionData?.billingBlocked)}
status={billingStatus === 'unknown' ? 'ok' : billingStatus}
percentUsed={Math.round(usage.percentUsed)}
percentUsed={
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.totalUsageLimit &&
organizationBillingData.totalUsageLimit > 0
? Math.round(
(organizationBillingData.totalCurrentUsage /
organizationBillingData.totalUsageLimit) *
100
)
: 0
: Math.round(usage.percentUsed)
}
onResolvePayment={async () => {
try {
const res = await fetch('/api/billing/portal', {
Expand All @@ -387,9 +404,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}}
rightContent={
!subscription.isFree &&
(permissions.canEditUsageLimit ||
permissions.showTeamMemberView ||
subscription.isEnterprise) ? (
(permissions.canEditUsageLimit || permissions.showTeamMemberView) ? (
<UsageLimit
ref={usageLimitRef}
currentLimit={
Expand All @@ -398,7 +413,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
: usageLimitData?.currentLimit || usage.limit
}
currentUsage={usage.current}
canEdit={permissions.canEditUsageLimit && !subscription.isEnterprise}
canEdit={permissions.canEditUsageLimit}
minimumLimit={
subscription.isTeam && isTeamAdmin
? organizationBillingData?.minimumBillingAmount ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,7 @@ export function Sidebar() {
<HelpModal open={showHelp} onOpenChange={setShowHelp} />
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
<SubscriptionModal open={showSubscriptionModal} onOpenChange={setShowSubscriptionModal} />

<SearchModal
open={showSearchModal}
onOpenChange={setShowSearchModal}
Expand Down
122 changes: 122 additions & 0 deletions apps/sim/components/emails/enterprise-subscription-email.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { format } from 'date-fns'
import { getBrandConfig } from '@/lib/branding/branding'
import { env } from '@/lib/env'
import { getAssetUrl } from '@/lib/utils'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'

interface EnterpriseSubscriptionEmailProps {
userName?: string
userEmail?: string
loginLink?: string
createdDate?: Date
}

const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'

export const EnterpriseSubscriptionEmail = ({
userName = 'Valued User',
userEmail = '',
loginLink = `${baseUrl}/login`,
createdDate = new Date(),
}: EnterpriseSubscriptionEmailProps) => {
const brand = getBrandConfig()

return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Your Enterprise Plan is now active on Sim</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || getAssetUrl('static/sim.png')}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>

<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>

<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
<Text style={baseStyles.paragraph}>
Great news! Your <strong>Enterprise Plan</strong> has been activated on Sim. You now
have access to advanced features and increased capacity for your workflows.
</Text>

<Text style={baseStyles.paragraph}>
Your account has been set up with full access to your organization. Click below to log
in and start exploring your new Enterprise features:
</Text>

<Link href={loginLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Access Your Enterprise Account</Text>
</Link>

<Text style={baseStyles.paragraph}>
<strong>What's next?</strong>
</Text>
<Text style={baseStyles.paragraph}>
• Invite team members to your organization
<br />• Begin building your workflows
</Text>

<Text style={baseStyles.paragraph}>
If you have any questions or need assistance getting started, our support team is here
to help.
</Text>

<Text style={baseStyles.paragraph}>
Welcome to Sim Enterprise!
<br />
The Sim Team
</Text>

<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This email was sent on {format(createdDate, 'MMMM do, yyyy')} to {userEmail}
regarding your Enterprise plan activation on Sim.
</Text>
</Section>
</Container>

<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}

export default EnterpriseSubscriptionEmail
1 change: 1 addition & 0 deletions apps/sim/components/emails/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './base-styles'
export { BatchInvitationEmail } from './batch-invitation-email'
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
export { default as EmailFooter } from './footer'
export { HelpConfirmationEmail } from './help-confirmation-email'
export { InvitationEmail } from './invitation-email'
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/components/emails/render-email.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { render } from '@react-email/components'
import {
BatchInvitationEmail,
EnterpriseSubscriptionEmail,
HelpConfirmationEmail,
InvitationEmail,
OTPVerificationEmail,
Expand Down Expand Up @@ -82,6 +83,23 @@ export async function renderHelpConfirmationEmail(
)
}

export async function renderEnterpriseSubscriptionEmail(
userName: string,
userEmail: string
): Promise<string> {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
const loginLink = `${baseUrl}/login`

return await render(
EnterpriseSubscriptionEmail({
userName,
userEmail,
loginLink,
createdDate: new Date(),
})
)
}

export function getEmailSubject(
type:
| 'sign-in'
Expand All @@ -91,6 +109,7 @@ export function getEmailSubject(
| 'invitation'
| 'batch-invitation'
| 'help-confirmation'
| 'enterprise-subscription'
): string {
const brandName = getBrandConfig().name

Expand All @@ -109,6 +128,8 @@ export function getEmailSubject(
return `You've been invited to join a team and workspaces on ${brandName}`
case 'help-confirmation':
return 'Your request has been received'
case 'enterprise-subscription':
return `Your Enterprise Plan is now active on ${brandName}`
default:
return brandName
}
Expand Down
117 changes: 1 addition & 116 deletions apps/sim/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
import { handleNewUser } from '@/lib/billing/core/usage'
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
import { getPlans } from '@/lib/billing/plans'
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
import {
handleInvoiceFinalized,
handleInvoicePaymentFailed,
Expand Down Expand Up @@ -52,121 +52,6 @@ if (validStripeKey) {
})
}

function isEnterpriseMetadata(value: unknown): value is EnterpriseSubscriptionMetadata {
return (
!!value &&
typeof (value as any).plan === 'string' &&
(value as any).plan.toLowerCase() === 'enterprise'
)
}

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 firstItem = stripeSubscription.items?.data?.[0]
const seats = typeof firstItem?.quantity === 'number' ? firstItem.quantity : null

if (!isEnterpriseMetadata(metadata)) {
logger.error('[subscription.created] Invalid enterprise metadata shape', {
subscriptionId: stripeSubscription.id,
metadata,
})
throw new Error('Invalid enterprise metadata for subscription')
}
const enterpriseMetadata = metadata
const metadataJson: Record<string, unknown> = { ...enterpriseMetadata }

const subscriptionRow = {
id: crypto.randomUUID(),
plan: 'enterprise',
referenceId,
stripeCustomerId,
stripeSubscriptionId: stripeSubscription.id,
status: stripeSubscription.status || null,
periodStart: stripeSubscription.current_period_start
? new Date(stripeSubscription.current_period_start * 1000)
: null,
periodEnd: stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: null,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null,
seats,
trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
: null,
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
metadata: metadataJson,
}

const existing = await db
.select({ id: schema.subscription.id })
.from(schema.subscription)
.where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id))
.limit(1)

if (existing.length > 0) {
await db
.update(schema.subscription)
.set({
plan: subscriptionRow.plan,
referenceId: subscriptionRow.referenceId,
stripeCustomerId: subscriptionRow.stripeCustomerId,
status: subscriptionRow.status,
periodStart: subscriptionRow.periodStart,
periodEnd: subscriptionRow.periodEnd,
cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd,
seats: subscriptionRow.seats,
trialStart: subscriptionRow.trialStart,
trialEnd: subscriptionRow.trialEnd,
metadata: subscriptionRow.metadata,
})
.where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id))
} else {
await db.insert(schema.subscription).values(subscriptionRow)
}

logger.info('[subscription.created] Upserted subscription', {
subscriptionId: subscriptionRow.id,
referenceId: subscriptionRow.referenceId,
plan: subscriptionRow.plan,
status: subscriptionRow.status,
})
}

export const auth = betterAuth({
baseURL: getBaseURL(),
trustedOrigins: [
Expand Down
Loading