Skip to content

Commit 91bfc08

Browse files
committed
billing, teammates
1 parent 107a98e commit 91bfc08

19 files changed

Lines changed: 688 additions & 23 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const SECTION_TITLES: Record<string, string> = {
1515
apikeys: 'Sim API Keys',
1616
byok: 'BYOK',
1717
subscription: 'Subscription',
18+
billing: 'Billing',
19+
teammates: 'Teammates',
1820
team: 'Team',
1921
sso: 'Single Sign-On',
2022
whitelabeling: 'Whitelabeling',

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import dynamic from 'next/dynamic'
55
import { useSearchParams } from 'next/navigation'
66
import { usePostHog } from 'posthog-js/react'
77
import { useSession } from '@/lib/auth/auth-client'
8+
import { isEnterprise } from '@/lib/billing/plan-helpers'
89
import { captureEvent } from '@/lib/posthog/client'
910
import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general'
1011
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
1112
import {
1213
isBillingEnabled,
1314
isCredentialSetsEnabled,
1415
} from '@/app/workspace/[workspaceId]/settings/navigation'
16+
import { useSubscriptionData } from '@/hooks/queries/subscription'
1517

1618
const Admin = dynamic(() =>
1719
import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin)
@@ -61,6 +63,14 @@ const Subscription = dynamic(() =>
6163
(m) => m.Subscription
6264
)
6365
)
66+
const Billing = dynamic(() =>
67+
import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then((m) => m.Billing)
68+
)
69+
const Teammates = dynamic(() =>
70+
import('@/app/workspace/[workspaceId]/settings/components/teammates/teammates').then(
71+
(m) => m.Teammates
72+
)
73+
)
6474
const TeamManagement = dynamic(() =>
6575
import('@/app/workspace/[workspaceId]/settings/components/team-management/team-management').then(
6676
(m) => m.TeamManagement
@@ -104,17 +114,26 @@ export function SettingsPage({ section }: SettingsPageProps) {
104114
const { data: session, isPending: sessionLoading } = useSession()
105115
const posthog = usePostHog()
106116

117+
const { data: subscriptionData } = useSubscriptionData({
118+
enabled: isBillingEnabled,
119+
staleTime: 5 * 60 * 1000,
120+
})
121+
const isEnterprisePlan = isEnterprise(subscriptionData?.data?.plan)
122+
107123
const isAdminRole = session?.user?.role === 'admin'
108124
const effectiveSection =
109-
!isBillingEnabled && (section === 'subscription' || section === 'organization')
125+
!isBillingEnabled &&
126+
(section === 'subscription' || section === 'billing' || section === 'organization')
110127
? 'general'
111-
: section === 'credential-sets' && !isCredentialSetsEnabled
128+
: section === 'billing' && isEnterprisePlan
112129
? 'general'
113-
: section === 'admin' && !sessionLoading && !isAdminRole
130+
: section === 'credential-sets' && !isCredentialSetsEnabled
114131
? 'general'
115-
: section === 'mothership' && !sessionLoading && !isAdminRole
132+
: section === 'admin' && !sessionLoading && !isAdminRole
116133
? 'general'
117-
: section
134+
: section === 'mothership' && !sessionLoading && !isAdminRole
135+
? 'general'
136+
: section
118137

119138
useEffect(() => {
120139
if (sessionLoading) return
@@ -130,6 +149,8 @@ export function SettingsPage({ section }: SettingsPageProps) {
130149
{effectiveSection === 'audit-logs' && <AuditLogs />}
131150
{effectiveSection === 'apikeys' && <ApiKeys />}
132151
{isBillingEnabled && effectiveSection === 'subscription' && <Subscription />}
152+
{isBillingEnabled && effectiveSection === 'billing' && <Billing />}
153+
{effectiveSection === 'teammates' && <Teammates />}
133154
{isBillingEnabled && effectiveSection === 'organization' && <TeamManagement />}
134155
{effectiveSection === 'sso' && <SSO />}
135156
{effectiveSection === 'data-retention' && <DataRetentionSettings />}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
'use client'
2+
3+
import { useCallback, useState } from 'react'
4+
import { useQueryClient } from '@tanstack/react-query'
5+
import { useParams, useRouter } from 'next/navigation'
6+
import { ArrowRight, ChipLink, Credit, Switch } from '@/components/emcn'
7+
import { getDisplayPlanName, getPlanTierDollars } from '@/lib/billing/plan-helpers'
8+
import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
9+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
10+
import { usePlanView } from '@/hooks/queries/plan-view'
11+
import { prefetchUpgradeBillingData, useSubscriptionData } from '@/hooks/queries/subscription'
12+
import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
13+
14+
interface InvoiceRow {
15+
id: string
16+
label: string
17+
amount: string
18+
}
19+
20+
export function Billing() {
21+
const { workspaceId } = useParams<{ workspaceId: string }>()
22+
const router = useRouter()
23+
const queryClient = useQueryClient()
24+
const { data } = useSubscriptionData()
25+
const { planView, isLoading, hasData } = usePlanView()
26+
27+
const [onDemandEnabled, setOnDemandEnabled] = useState(false)
28+
29+
const upgradeHref = `/workspace/${workspaceId}/upgrade`
30+
31+
/**
32+
* Warm the Upgrade route bundle and the exact queries that page gates on, so
33+
* the click navigates into already-cached data instead of a loading state.
34+
*/
35+
const prefetchUpgrade = useCallback(() => {
36+
router.prefetch(upgradeHref)
37+
prefetchUpgradeBillingData(queryClient)
38+
prefetchWorkspaceSettings(queryClient, workspaceId)
39+
}, [router, queryClient, upgradeHref, workspaceId])
40+
41+
if (isLoading || !hasData) return null
42+
if (planView.isEnterprise) return null
43+
44+
const isFree = planView.isFree
45+
const plan = data?.data?.plan ?? 'free'
46+
const planName = getDisplayPlanName(plan)
47+
const billingPeriod =
48+
data?.data?.billingInterval === 'year' ? 'billed annually' : 'billed monthly'
49+
const priceText = `$${getPlanTierDollars(plan)} per user/month, ${billingPeriod}`
50+
51+
// Invoices are managed in Stripe and not yet exposed to the frontend, so this
52+
// is empty for now and the Invoices section below is hidden when there are none.
53+
const invoices: InvoiceRow[] = []
54+
55+
return (
56+
<div className='flex h-full flex-col bg-[var(--bg)]'>
57+
<div className='flex flex-shrink-0 items-center justify-between bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
58+
<div />
59+
<div className='h-[30px]' />
60+
</div>
61+
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
62+
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
63+
<div className='flex flex-col gap-1'>
64+
<h1 className='font-medium text-[var(--text-body)] text-lg'>Billing</h1>
65+
<p className='text-[var(--text-muted)] text-md'>
66+
Manage your plan, pricing, and invoices.
67+
</p>
68+
</div>
69+
70+
<div className='flex items-center justify-between gap-3'>
71+
<div className='flex items-center gap-2.5'>
72+
<div className='size-9 flex-shrink-0'>
73+
<div className='flex size-full items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
74+
<Credit className='size-5 text-[var(--text-icon)]' />
75+
</div>
76+
</div>
77+
<div className='flex min-w-0 flex-col'>
78+
<span className='truncate text-[14px] text-[var(--text-body)]'>
79+
{planName} plan
80+
</span>
81+
<span className='truncate text-[12px] text-[var(--text-muted)]'>{priceText}</span>
82+
</div>
83+
</div>
84+
<ChipLink
85+
href={upgradeHref}
86+
variant='border-shadow'
87+
flush
88+
onMouseEnter={prefetchUpgrade}
89+
onFocus={prefetchUpgrade}
90+
>
91+
Explore plans
92+
</ChipLink>
93+
</div>
94+
95+
{!isFree && (
96+
<>
97+
<UsageLimitField />
98+
99+
<SettingsSection label='Enable on-demand usage'>
100+
<div className='flex items-center justify-between'>
101+
<span className='text-[var(--text-body)] text-small'>
102+
Allow usage to go past usage limit
103+
</span>
104+
<Switch checked={onDemandEnabled} onCheckedChange={setOnDemandEnabled} />
105+
</div>
106+
</SettingsSection>
107+
108+
{invoices.length > 0 && (
109+
<SettingsSection label='Invoices'>
110+
<div className='-mx-2 flex flex-col gap-y-0.5'>
111+
{invoices.map((invoice) => (
112+
<button
113+
key={invoice.id}
114+
type='button'
115+
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
116+
>
117+
<span className='min-w-0 flex-1 truncate text-[14px] text-[var(--text-body)]'>
118+
{invoice.label}
119+
</span>
120+
<span className='flex-shrink-0 text-[12px] text-[var(--text-muted)]'>
121+
{invoice.amount}
122+
</span>
123+
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
124+
</button>
125+
))}
126+
</div>
127+
</SettingsSection>
128+
)}
129+
</>
130+
)}
131+
</div>
132+
</div>
133+
</div>
134+
)
135+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
'use client'
2+
3+
import { useEffect, useRef, useState } from 'react'
4+
import { getErrorMessage } from '@sim/utils/errors'
5+
import { Info, toast } from '@/components/emcn'
6+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
7+
import { useUpdateUsageLimit, useUsageLimitData } from '@/hooks/queries/subscription'
8+
import { useDebounce } from '@/hooks/use-debounce'
9+
10+
/** Delay before a usage-limit edit is auto-saved once the user stops typing. */
11+
const AUTOSAVE_DELAY_MS = 1000
12+
13+
/**
14+
* Editable monthly usage-limit field. Seeds from the user's saved limit and
15+
* auto-saves a debounced, validated value. Renders nothing for plans that
16+
* cannot edit their limit (e.g. the free plan), so the whole section is hidden.
17+
*/
18+
export function UsageLimitField() {
19+
const { data: usageLimit } = useUsageLimitData()
20+
const { mutate: saveLimit } = useUpdateUsageLimit()
21+
22+
const currentLimit = usageLimit?.data?.currentLimit
23+
const minimumLimit = usageLimit?.data?.minimumLimit ?? 0
24+
const canEdit = usageLimit?.data?.canEdit ?? false
25+
26+
const [draft, setDraft] = useState('')
27+
const debouncedDraft = useDebounce(draft, AUTOSAVE_DELAY_MS)
28+
const syncedRef = useRef<number | null>(null)
29+
30+
useEffect(() => {
31+
if (currentLimit == null || syncedRef.current === currentLimit) return
32+
const isClean = draft === '' || draft === String(syncedRef.current)
33+
syncedRef.current = currentLimit
34+
if (isClean) setDraft(String(currentLimit))
35+
}, [currentLimit, draft])
36+
37+
useEffect(() => {
38+
if (currentLimit == null || debouncedDraft.trim() === '') return
39+
const parsed = Number.parseFloat(debouncedDraft)
40+
if (Number.isNaN(parsed)) {
41+
toast.error('Usage limit must be a number')
42+
return
43+
}
44+
if (parsed === currentLimit) return
45+
if (parsed < minimumLimit) {
46+
toast.error(`Usage limit must be at least ${minimumLimit}`)
47+
return
48+
}
49+
saveLimit(
50+
{ limit: parsed },
51+
{
52+
onError: (error) => {
53+
toast.error("Couldn't update usage limit", {
54+
description: getErrorMessage(error, 'Please try again in a moment.'),
55+
})
56+
},
57+
}
58+
)
59+
}, [debouncedDraft, currentLimit, minimumLimit, saveLimit])
60+
61+
if (!canEdit) return null
62+
63+
return (
64+
<SettingsSection
65+
label='Usage limit'
66+
headerAccessory={
67+
<Info side='top' className='text-[var(--text-muted)]'>
68+
{
69+
"Max usage to consume per month. By default, it's your plan's limit, but you can set it beyond."
70+
}
71+
</Info>
72+
}
73+
>
74+
<div className='flex h-[30px] items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 dark:bg-[var(--surface-4)]'>
75+
<input
76+
type='number'
77+
inputMode='decimal'
78+
min={minimumLimit}
79+
value={draft}
80+
onChange={(e) => setDraft(e.target.value)}
81+
placeholder={currentLimit != null ? String(currentLimit) : 'Enter monthly usage limit'}
82+
className='h-full w-full bg-transparent text-[var(--text-body)] text-sm outline-none [appearance:textfield] placeholder:text-[var(--text-muted)] focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
83+
/>
84+
</div>
85+
</SettingsSection>
86+
)
87+
}

apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@ import type { ReactNode } from 'react'
22

33
interface SettingsSectionProps {
44
label: string
5+
/** Optional node rendered immediately to the right of the label (e.g. an info tooltip). */
6+
headerAccessory?: ReactNode
57
children: ReactNode
68
}
79

810
/**
911
* Labeled section primitive that matches the integrations page visual rhythm:
1012
* a muted small label, a thin divider, then the body content.
1113
*/
12-
export function SettingsSection({ label, children }: SettingsSectionProps) {
14+
export function SettingsSection({ label, headerAccessory, children }: SettingsSectionProps) {
1315
return (
1416
<section className='flex flex-col'>
15-
<span className='pl-0.5 text-[var(--text-muted)] text-small'>{label}</span>
17+
<div className='flex items-center gap-1.5 pl-0.5'>
18+
<span className='text-[var(--text-muted)] text-small'>{label}</span>
19+
{headerAccessory}
20+
</div>
1621
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
1722
{children}
1823
</section>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { Teammates } from './teammates'

0 commit comments

Comments
 (0)