Skip to content

Commit 7c455ce

Browse files
improvement(billing): wire up billing, org, teammates tabs + remove deprecated subscription tab (#4887)
* improvement(billing): wire up billing, org, teammates tabs + remove depr subscription tab * pass exec timeout to tool routes * reuse helper * address comments * address disable comment
1 parent 469781f commit 7c455ce

69 files changed

Lines changed: 1986 additions & 5719 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { db } from '@sim/db'
2+
import { user } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { getInvoicesContract } from '@/lib/api/contracts/subscription'
7+
import { parseRequest } from '@/lib/api/server'
8+
import { getSession } from '@/lib/auth'
9+
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
10+
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
11+
import { getStripeClient } from '@/lib/billing/stripe-client'
12+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13+
14+
const logger = createLogger('BillingInvoices')
15+
16+
/** Cap the number of invoices returned to the most recent statements. */
17+
const MAX_INVOICES = 12
18+
19+
/**
20+
* Lists finalized Stripe invoices for the caller's billing customer (personal
21+
* or organization-scoped). Returns an empty list when there is no Stripe
22+
* customer yet or when Stripe is not configured, so the UI can simply hide the
23+
* Invoices section instead of surfacing an error.
24+
*/
25+
export const GET = withRouteHandler(async (request: NextRequest) => {
26+
const session = await getSession()
27+
if (!session?.user?.id) {
28+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
29+
}
30+
31+
const parsed = await parseRequest(getInvoicesContract, request, {})
32+
if (!parsed.success) return parsed.response
33+
34+
const { context, organizationId } = parsed.data.query
35+
36+
if (context === 'organization' && !organizationId) {
37+
return NextResponse.json(
38+
{ error: 'organizationId is required when context=organization' },
39+
{ status: 400 }
40+
)
41+
}
42+
43+
let stripeCustomerId: string | null = null
44+
45+
if (context === 'organization') {
46+
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId!)
47+
if (!hasPermission) {
48+
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
49+
}
50+
51+
// Resolve the org's customer via the canonical resolver so we deterministically
52+
// pick the same subscription (most recent entitled, ordered) the rest of the
53+
// billing UI uses — a bare limit(1) here could select a stale row.
54+
const orgSubscription = await getOrganizationSubscription(organizationId!)
55+
stripeCustomerId = orgSubscription?.stripeCustomerId ?? null
56+
} else {
57+
const rows = await db
58+
.select({ customer: user.stripeCustomerId })
59+
.from(user)
60+
.where(eq(user.id, session.user.id))
61+
.limit(1)
62+
63+
stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
64+
}
65+
66+
const stripe = getStripeClient()
67+
if (!stripeCustomerId || !stripe) {
68+
return NextResponse.json({ success: true, invoices: [], hasMore: false })
69+
}
70+
71+
try {
72+
const result = await stripe.invoices.list({ customer: stripeCustomerId, limit: MAX_INVOICES })
73+
74+
const invoices = result.data
75+
.filter((invoice) => invoice.id && invoice.status && invoice.status !== 'draft')
76+
.map((invoice) => ({
77+
id: invoice.id as string,
78+
number: invoice.number ?? null,
79+
created: invoice.created,
80+
total: invoice.total,
81+
amountPaid: invoice.amount_paid,
82+
currency: invoice.currency,
83+
status: invoice.status ?? null,
84+
hostedInvoiceUrl: invoice.hosted_invoice_url ?? null,
85+
invoicePdf: invoice.invoice_pdf ?? null,
86+
}))
87+
88+
return NextResponse.json({ success: true, invoices, hasMore: result.has_more })
89+
} catch (error) {
90+
logger.error('Failed to list invoices', { error, userId: session.user.id, context })
91+
return NextResponse.json({ error: 'Failed to list invoices' }, { status: 500 })
92+
}
93+
})

apps/sim/app/api/billing/portal/route.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { db } from '@sim/db'
2-
import { subscription as subscriptionTable, user } from '@sim/db/schema'
2+
import { user } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq, inArray, or } from 'drizzle-orm'
4+
import { eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { billingPortalBodySchema } from '@/lib/api/contracts/subscription'
77
import { getSession } from '@/lib/auth'
8+
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
89
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
910
import { requireStripeClient } from '@/lib/billing/stripe-client'
10-
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
1111
import { getBaseUrl } from '@/lib/core/utils/urls'
1212
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1313

@@ -44,21 +44,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4444
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
4545
}
4646

47-
const rows = await db
48-
.select({ customer: subscriptionTable.stripeCustomerId })
49-
.from(subscriptionTable)
50-
.where(
51-
and(
52-
eq(subscriptionTable.referenceId, organizationId),
53-
or(
54-
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
55-
eq(subscriptionTable.cancelAtPeriodEnd, true)
56-
)
57-
)
58-
)
59-
.limit(1)
60-
61-
stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
47+
// Canonical resolver: deterministically selects the most recent entitled
48+
// org subscription, matching the rest of the billing UI.
49+
const orgSubscription = await getOrganizationSubscription(organizationId)
50+
stripeCustomerId = orgSubscription?.stripeCustomerId ?? null
6251
} else {
6352
const rows = await db
6453
.select({ customer: user.stripeCustomerId })

apps/sim/app/api/tools/image/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ const MAX_IMAGE_BYTES = 25 * 1024 * 1024
3939
const MAX_IMAGE_JSON_BYTES = Math.ceil((MAX_IMAGE_BYTES * 4) / 3) + 256 * 1024
4040

4141
export const dynamic = 'force-dynamic'
42-
export const maxDuration = 600
42+
/**
43+
* Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by
44+
* `getMaxExecutionTimeout()` for the provider polling loop below. Next.js requires a
45+
* static literal for `maxDuration`, so this value must be kept in sync with that source.
46+
*/
47+
export const maxDuration = 5400
4348

4449
type ImageProvider = (typeof imageProviders)[number]
4550

apps/sim/app/api/tools/stt/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { sttToolContract } from '@/lib/api/contracts/tools/media/stt'
77
import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server'
88
import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor'
99
import { checkInternalAuth } from '@/lib/auth/hybrid'
10-
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
10+
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
1111
import {
1212
secureFetchWithPinnedIP,
1313
validateUrlWithDNS,
@@ -25,7 +25,12 @@ const logger = createLogger('SttProxyAPI')
2525
const ELEVENLABS_STT_MODEL = 'scribe_v2'
2626

2727
export const dynamic = 'force-dynamic'
28-
export const maxDuration = 300 // 5 minutes for large files
28+
/**
29+
* Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by
30+
* `getMaxExecutionTimeout()` for the transcript polling loop below. Next.js requires a
31+
* static literal for `maxDuration`, so this value must be kept in sync with that source.
32+
*/
33+
export const maxDuration = 5400
2934

3035
export const POST = withRouteHandler(async (request: NextRequest) => {
3136
const requestId = generateId()
@@ -629,7 +634,7 @@ async function transcribeWithAssemblyAI(
629634
let transcript: any
630635
let attempts = 0
631636
const pollIntervalMs = 5000
632-
const maxAttempts = Math.ceil(DEFAULT_EXECUTION_TIMEOUT_MS / pollIntervalMs)
637+
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
633638

634639
while (attempts < maxAttempts) {
635640
const statusResponse = await fetch(`https://api.assemblyai.com/v2/transcript/${id}`, {

apps/sim/app/api/tools/textract/parse/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { textractParseContract } from '@/lib/api/contracts/tools/media/document-parse'
77
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
88
import { checkInternalAuth } from '@/lib/auth/hybrid'
9-
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
9+
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
1010
import { validateS3BucketName } from '@/lib/core/security/input-validation'
1111
import {
1212
secureFetchWithPinnedIP,
@@ -22,7 +22,12 @@ import {
2222
import { assertToolFileAccess } from '@/app/api/files/authorization'
2323

2424
export const dynamic = 'force-dynamic'
25-
export const maxDuration = 300 // 5 minutes for large multi-page PDF processing
25+
/**
26+
* Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by
27+
* `getMaxExecutionTimeout()` for the job polling loop below. Next.js requires a static
28+
* literal for `maxDuration`, so this value must be kept in sync with that source.
29+
*/
30+
export const maxDuration = 5400
2631

2732
const logger = createLogger('TextractParseAPI')
2833

@@ -184,7 +189,7 @@ async function pollForJobCompletion(
184189
requestId: string
185190
): Promise<Record<string, unknown>> {
186191
const pollIntervalMs = 5000
187-
const maxPollTimeMs = DEFAULT_EXECUTION_TIMEOUT_MS
192+
const maxPollTimeMs = getMaxExecutionTimeout()
188193
const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs)
189194

190195
const getTarget = useAnalyzeDocument

apps/sim/app/api/tools/video/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ const MAX_VIDEO_REFERENCE_IMAGE_BYTES = 25 * 1024 * 1024
2828
const MAX_VIDEO_JSON_BYTES = 2 * 1024 * 1024
2929

3030
export const dynamic = 'force-dynamic'
31-
export const maxDuration = 600 // 10 minutes for video generation
31+
/**
32+
* Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by
33+
* `getMaxExecutionTimeout()` for the provider polling loops below. Next.js requires a
34+
* static literal for `maxDuration`, so this value must be kept in sync with that source.
35+
*/
36+
export const maxDuration = 5400
3237

3338
async function readVideoResponseBuffer(response: Response, label: string): Promise<Buffer> {
3439
return readResponseToBufferWithLimit(response, {

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,7 @@ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
637637

638638
function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
639639
const { workspaceId } = useParams<{ workspaceId: string }>()
640-
const settingsPath = `/workspace/${workspaceId}/settings/subscription`
640+
const settingsPath = `/workspace/${workspaceId}/settings/billing`
641641
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
642642

643643
return (

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
276276
}
277277

278278
if (usageExceeded) {
279-
navigateToSettings({ section: 'subscription' })
279+
navigateToSettings({ section: 'billing' })
280280
return
281281
}
282282

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
393393
}
394394

395395
function handleUsageLimitExceeded() {
396-
navigateToSettings({ section: 'subscription' })
396+
navigateToSettings({ section: 'billing' })
397397
}
398398

399399
const {

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

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@ 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'
98
import { captureEvent } from '@/lib/posthog/client'
109
import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general'
1110
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
1211
import {
1312
isBillingEnabled,
1413
isCredentialSetsEnabled,
1514
} from '@/app/workspace/[workspaceId]/settings/navigation'
16-
import { useSubscriptionData } from '@/hooks/queries/subscription'
1715

1816
const Admin = dynamic(() =>
1917
import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin)
@@ -58,11 +56,6 @@ const RecentlyDeleted = dynamic(() =>
5856
'@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted'
5957
).then((m) => m.RecentlyDeleted)
6058
)
61-
const Subscription = dynamic(() =>
62-
import('@/app/workspace/[workspaceId]/settings/components/subscription/subscription').then(
63-
(m) => m.Subscription
64-
)
65-
)
6659
const Billing = dynamic(() =>
6760
import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then((m) => m.Billing)
6861
)
@@ -114,26 +107,20 @@ export function SettingsPage({ section }: SettingsPageProps) {
114107
const { data: session, isPending: sessionLoading } = useSession()
115108
const posthog = usePostHog()
116109

117-
const { data: subscriptionData } = useSubscriptionData({
118-
enabled: isBillingEnabled,
119-
staleTime: 5 * 60 * 1000,
120-
})
121-
const isEnterprisePlan = isEnterprise(subscriptionData?.data?.plan)
122-
123110
const isAdminRole = session?.user?.role === 'admin'
111+
// The Subscription tab was replaced by Billing; redirect legacy links there.
112+
const normalizedSection: SettingsSection =
113+
(section as string) === 'subscription' ? 'billing' : section
124114
const effectiveSection =
125-
!isBillingEnabled &&
126-
(section === 'subscription' || section === 'billing' || section === 'organization')
115+
!isBillingEnabled && (normalizedSection === 'billing' || normalizedSection === 'organization')
127116
? 'general'
128-
: section === 'billing' && isEnterprisePlan
117+
: normalizedSection === 'credential-sets' && !isCredentialSetsEnabled
129118
? 'general'
130-
: section === 'credential-sets' && !isCredentialSetsEnabled
119+
: normalizedSection === 'admin' && !sessionLoading && !isAdminRole
131120
? 'general'
132-
: section === 'admin' && !sessionLoading && !isAdminRole
121+
: normalizedSection === 'mothership' && !sessionLoading && !isAdminRole
133122
? 'general'
134-
: section === 'mothership' && !sessionLoading && !isAdminRole
135-
? 'general'
136-
: section
123+
: normalizedSection
137124

138125
useEffect(() => {
139126
if (sessionLoading) return
@@ -148,7 +135,6 @@ export function SettingsPage({ section }: SettingsPageProps) {
148135
{effectiveSection === 'access-control' && <AccessControl />}
149136
{effectiveSection === 'audit-logs' && <AuditLogs />}
150137
{effectiveSection === 'apikeys' && <ApiKeys />}
151-
{isBillingEnabled && effectiveSection === 'subscription' && <Subscription />}
152138
{isBillingEnabled && effectiveSection === 'billing' && <Billing />}
153139
{effectiveSection === 'teammates' && <Teammates />}
154140
{isBillingEnabled && effectiveSection === 'organization' && <TeamManagement />}

0 commit comments

Comments
 (0)