Skip to content

Commit 7780d9b

Browse files
fix(enterprise-billing): simplification to be fixed-cost (#1196)
* fix(enterprise-billing): simplify * conceptual improvement * add seats to enterprise sub meta * correct type * fix UI * send emails to new enterprise users * fix fallback * fix merge conflict issue --------- Co-authored-by: waleedlatif1 <[email protected]>
1 parent 4a703a0 commit 7780d9b

File tree

20 files changed

+555
-417
lines changed

20 files changed

+555
-417
lines changed

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,10 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
197197
const activeOrgId = activeOrganization?.id
198198

199199
useEffect(() => {
200-
if (subscription.isTeam && activeOrgId) {
200+
if ((subscription.isTeam || subscription.isEnterprise) && activeOrgId) {
201201
loadOrganizationBillingData(activeOrgId)
202202
}
203-
}, [activeOrgId, subscription.isTeam, loadOrganizationBillingData])
203+
}, [activeOrgId, subscription.isTeam, subscription.isEnterprise, loadOrganizationBillingData])
204204

205205
// Auto-clear upgrade error
206206
useEffect(() => {
@@ -349,22 +349,39 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
349349
badgeText={badgeText}
350350
onBadgeClick={handleBadgeClick}
351351
seatsText={
352-
permissions.canManageTeam
352+
permissions.canManageTeam || subscription.isEnterprise
353353
? `${organizationBillingData?.totalSeats || subscription.seats || 1} seats`
354354
: undefined
355355
}
356-
current={usage.current}
356+
current={
357+
subscription.isEnterprise || subscription.isTeam
358+
? organizationBillingData?.totalCurrentUsage || 0
359+
: usage.current
360+
}
357361
limit={
358-
!subscription.isFree &&
359-
(permissions.canEditUsageLimit ||
360-
permissions.showTeamMemberView ||
361-
subscription.isEnterprise)
362-
? usage.current // placeholder; rightContent will render UsageLimit
363-
: usage.limit
362+
subscription.isEnterprise || subscription.isTeam
363+
? organizationBillingData?.totalUsageLimit ||
364+
organizationBillingData?.minimumBillingAmount ||
365+
0
366+
: !subscription.isFree &&
367+
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
368+
? usage.current // placeholder; rightContent will render UsageLimit
369+
: usage.limit
364370
}
365371
isBlocked={Boolean(subscriptionData?.billingBlocked)}
366372
status={billingStatus === 'unknown' ? 'ok' : billingStatus}
367-
percentUsed={Math.round(usage.percentUsed)}
373+
percentUsed={
374+
subscription.isEnterprise || subscription.isTeam
375+
? organizationBillingData?.totalUsageLimit &&
376+
organizationBillingData.totalUsageLimit > 0
377+
? Math.round(
378+
(organizationBillingData.totalCurrentUsage /
379+
organizationBillingData.totalUsageLimit) *
380+
100
381+
)
382+
: 0
383+
: Math.round(usage.percentUsed)
384+
}
368385
onResolvePayment={async () => {
369386
try {
370387
const res = await fetch('/api/billing/portal', {
@@ -387,9 +404,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
387404
}}
388405
rightContent={
389406
!subscription.isFree &&
390-
(permissions.canEditUsageLimit ||
391-
permissions.showTeamMemberView ||
392-
subscription.isEnterprise) ? (
407+
(permissions.canEditUsageLimit || permissions.showTeamMemberView) ? (
393408
<UsageLimit
394409
ref={usageLimitRef}
395410
currentLimit={
@@ -398,7 +413,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
398413
: usageLimitData?.currentLimit || usage.limit
399414
}
400415
currentUsage={usage.current}
401-
canEdit={permissions.canEditUsageLimit && !subscription.isEnterprise}
416+
canEdit={permissions.canEditUsageLimit}
402417
minimumLimit={
403418
subscription.isTeam && isTeamAdmin
404419
? organizationBillingData?.minimumBillingAmount ||

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,7 @@ export function Sidebar() {
10391039
<HelpModal open={showHelp} onOpenChange={setShowHelp} />
10401040
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
10411041
<SubscriptionModal open={showSubscriptionModal} onOpenChange={setShowSubscriptionModal} />
1042+
10421043
<SearchModal
10431044
open={showSearchModal}
10441045
onOpenChange={setShowSearchModal}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {
2+
Body,
3+
Column,
4+
Container,
5+
Head,
6+
Html,
7+
Img,
8+
Link,
9+
Preview,
10+
Row,
11+
Section,
12+
Text,
13+
} from '@react-email/components'
14+
import { format } from 'date-fns'
15+
import { getBrandConfig } from '@/lib/branding/branding'
16+
import { env } from '@/lib/env'
17+
import { getAssetUrl } from '@/lib/utils'
18+
import { baseStyles } from './base-styles'
19+
import EmailFooter from './footer'
20+
21+
interface EnterpriseSubscriptionEmailProps {
22+
userName?: string
23+
userEmail?: string
24+
loginLink?: string
25+
createdDate?: Date
26+
}
27+
28+
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
29+
30+
export const EnterpriseSubscriptionEmail = ({
31+
userName = 'Valued User',
32+
userEmail = '',
33+
loginLink = `${baseUrl}/login`,
34+
createdDate = new Date(),
35+
}: EnterpriseSubscriptionEmailProps) => {
36+
const brand = getBrandConfig()
37+
38+
return (
39+
<Html>
40+
<Head />
41+
<Body style={baseStyles.main}>
42+
<Preview>Your Enterprise Plan is now active on Sim</Preview>
43+
<Container style={baseStyles.container}>
44+
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
45+
<Row>
46+
<Column style={{ textAlign: 'center' }}>
47+
<Img
48+
src={brand.logoUrl || getAssetUrl('static/sim.png')}
49+
width='114'
50+
alt={brand.name}
51+
style={{
52+
margin: '0 auto',
53+
}}
54+
/>
55+
</Column>
56+
</Row>
57+
</Section>
58+
59+
<Section style={baseStyles.sectionsBorders}>
60+
<Row>
61+
<Column style={baseStyles.sectionBorder} />
62+
<Column style={baseStyles.sectionCenter} />
63+
<Column style={baseStyles.sectionBorder} />
64+
</Row>
65+
</Section>
66+
67+
<Section style={baseStyles.content}>
68+
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
69+
<Text style={baseStyles.paragraph}>
70+
Great news! Your <strong>Enterprise Plan</strong> has been activated on Sim. You now
71+
have access to advanced features and increased capacity for your workflows.
72+
</Text>
73+
74+
<Text style={baseStyles.paragraph}>
75+
Your account has been set up with full access to your organization. Click below to log
76+
in and start exploring your new Enterprise features:
77+
</Text>
78+
79+
<Link href={loginLink} style={{ textDecoration: 'none' }}>
80+
<Text style={baseStyles.button}>Access Your Enterprise Account</Text>
81+
</Link>
82+
83+
<Text style={baseStyles.paragraph}>
84+
<strong>What's next?</strong>
85+
</Text>
86+
<Text style={baseStyles.paragraph}>
87+
• Invite team members to your organization
88+
<br />• Begin building your workflows
89+
</Text>
90+
91+
<Text style={baseStyles.paragraph}>
92+
If you have any questions or need assistance getting started, our support team is here
93+
to help.
94+
</Text>
95+
96+
<Text style={baseStyles.paragraph}>
97+
Welcome to Sim Enterprise!
98+
<br />
99+
The Sim Team
100+
</Text>
101+
102+
<Text
103+
style={{
104+
...baseStyles.footerText,
105+
marginTop: '40px',
106+
textAlign: 'left',
107+
color: '#666666',
108+
}}
109+
>
110+
This email was sent on {format(createdDate, 'MMMM do, yyyy')} to {userEmail}
111+
regarding your Enterprise plan activation on Sim.
112+
</Text>
113+
</Section>
114+
</Container>
115+
116+
<EmailFooter baseUrl={baseUrl} />
117+
</Body>
118+
</Html>
119+
)
120+
}
121+
122+
export default EnterpriseSubscriptionEmail

apps/sim/components/emails/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './base-styles'
22
export { BatchInvitationEmail } from './batch-invitation-email'
3+
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
34
export { default as EmailFooter } from './footer'
45
export { HelpConfirmationEmail } from './help-confirmation-email'
56
export { InvitationEmail } from './invitation-email'

apps/sim/components/emails/render-email.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { render } from '@react-email/components'
22
import {
33
BatchInvitationEmail,
4+
EnterpriseSubscriptionEmail,
45
HelpConfirmationEmail,
56
InvitationEmail,
67
OTPVerificationEmail,
@@ -82,6 +83,23 @@ export async function renderHelpConfirmationEmail(
8283
)
8384
}
8485

86+
export async function renderEnterpriseSubscriptionEmail(
87+
userName: string,
88+
userEmail: string
89+
): Promise<string> {
90+
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
91+
const loginLink = `${baseUrl}/login`
92+
93+
return await render(
94+
EnterpriseSubscriptionEmail({
95+
userName,
96+
userEmail,
97+
loginLink,
98+
createdDate: new Date(),
99+
})
100+
)
101+
}
102+
85103
export function getEmailSubject(
86104
type:
87105
| 'sign-in'
@@ -91,6 +109,7 @@ export function getEmailSubject(
91109
| 'invitation'
92110
| 'batch-invitation'
93111
| 'help-confirmation'
112+
| 'enterprise-subscription'
94113
): string {
95114
const brandName = getBrandConfig().name
96115

@@ -109,6 +128,8 @@ export function getEmailSubject(
109128
return `You've been invited to join a team and workspaces on ${brandName}`
110129
case 'help-confirmation':
111130
return 'Your request has been received'
131+
case 'enterprise-subscription':
132+
return `Your Enterprise Plan is now active on ${brandName}`
112133
default:
113134
return brandName
114135
}

apps/sim/lib/auth.ts

Lines changed: 1 addition & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
2424
import { handleNewUser } from '@/lib/billing/core/usage'
2525
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
2626
import { getPlans } from '@/lib/billing/plans'
27-
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
27+
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
2828
import {
2929
handleInvoiceFinalized,
3030
handleInvoicePaymentFailed,
@@ -52,121 +52,6 @@ if (validStripeKey) {
5252
})
5353
}
5454

55-
function isEnterpriseMetadata(value: unknown): value is EnterpriseSubscriptionMetadata {
56-
return (
57-
!!value &&
58-
typeof (value as any).plan === 'string' &&
59-
(value as any).plan.toLowerCase() === 'enterprise'
60-
)
61-
}
62-
63-
async function handleManualEnterpriseSubscription(event: Stripe.Event) {
64-
const stripeSubscription = event.data.object as Stripe.Subscription
65-
66-
const metaPlan = (stripeSubscription.metadata?.plan as string | undefined)?.toLowerCase() || ''
67-
68-
if (metaPlan !== 'enterprise') {
69-
logger.info('[subscription.created] Skipping non-enterprise subscription', {
70-
subscriptionId: stripeSubscription.id,
71-
plan: metaPlan || 'unknown',
72-
})
73-
return
74-
}
75-
76-
const stripeCustomerId = stripeSubscription.customer as string
77-
78-
if (!stripeCustomerId) {
79-
logger.error('[subscription.created] Missing Stripe customer ID', {
80-
subscriptionId: stripeSubscription.id,
81-
})
82-
throw new Error('Missing Stripe customer ID on subscription')
83-
}
84-
85-
const metadata = stripeSubscription.metadata || {}
86-
87-
const referenceId =
88-
typeof metadata.referenceId === 'string' && metadata.referenceId.length > 0
89-
? metadata.referenceId
90-
: null
91-
92-
if (!referenceId) {
93-
logger.error('[subscription.created] Unable to resolve referenceId', {
94-
subscriptionId: stripeSubscription.id,
95-
stripeCustomerId,
96-
})
97-
throw new Error('Unable to resolve referenceId for subscription')
98-
}
99-
100-
const firstItem = stripeSubscription.items?.data?.[0]
101-
const seats = typeof firstItem?.quantity === 'number' ? firstItem.quantity : null
102-
103-
if (!isEnterpriseMetadata(metadata)) {
104-
logger.error('[subscription.created] Invalid enterprise metadata shape', {
105-
subscriptionId: stripeSubscription.id,
106-
metadata,
107-
})
108-
throw new Error('Invalid enterprise metadata for subscription')
109-
}
110-
const enterpriseMetadata = metadata
111-
const metadataJson: Record<string, unknown> = { ...enterpriseMetadata }
112-
113-
const subscriptionRow = {
114-
id: crypto.randomUUID(),
115-
plan: 'enterprise',
116-
referenceId,
117-
stripeCustomerId,
118-
stripeSubscriptionId: stripeSubscription.id,
119-
status: stripeSubscription.status || null,
120-
periodStart: stripeSubscription.current_period_start
121-
? new Date(stripeSubscription.current_period_start * 1000)
122-
: null,
123-
periodEnd: stripeSubscription.current_period_end
124-
? new Date(stripeSubscription.current_period_end * 1000)
125-
: null,
126-
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null,
127-
seats,
128-
trialStart: stripeSubscription.trial_start
129-
? new Date(stripeSubscription.trial_start * 1000)
130-
: null,
131-
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
132-
metadata: metadataJson,
133-
}
134-
135-
const existing = await db
136-
.select({ id: schema.subscription.id })
137-
.from(schema.subscription)
138-
.where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id))
139-
.limit(1)
140-
141-
if (existing.length > 0) {
142-
await db
143-
.update(schema.subscription)
144-
.set({
145-
plan: subscriptionRow.plan,
146-
referenceId: subscriptionRow.referenceId,
147-
stripeCustomerId: subscriptionRow.stripeCustomerId,
148-
status: subscriptionRow.status,
149-
periodStart: subscriptionRow.periodStart,
150-
periodEnd: subscriptionRow.periodEnd,
151-
cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd,
152-
seats: subscriptionRow.seats,
153-
trialStart: subscriptionRow.trialStart,
154-
trialEnd: subscriptionRow.trialEnd,
155-
metadata: subscriptionRow.metadata,
156-
})
157-
.where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id))
158-
} else {
159-
await db.insert(schema.subscription).values(subscriptionRow)
160-
}
161-
162-
logger.info('[subscription.created] Upserted subscription', {
163-
subscriptionId: subscriptionRow.id,
164-
referenceId: subscriptionRow.referenceId,
165-
plan: subscriptionRow.plan,
166-
status: subscriptionRow.status,
167-
})
168-
}
169-
17055
export const auth = betterAuth({
17156
baseURL: getBaseURL(),
17257
trustedOrigins: [

0 commit comments

Comments
 (0)