Skip to content

Commit 56543da

Browse files
fix(billing): usage tracking cleanup, shared pool of limits for team/enterprise (#1131)
* fix(billing): team usage tracking cleanup, shared pool of limits for team * address greptile commments * fix lint * remove usage of deprecated cols" * update periodStart and periodEnd correctly * fix lint * fix type issue * fix(billing): cleaned up billing, still more work to do on UI and population of data and consolidation * fix upgrade * cleanup * progress * works * Remove 78th migration to prepare for merge with staging * fix migration conflict * remove useless test file * fix * Fix undefined seat pricing display and handle cancelled subscription seat updates * cleanup code * cleanup to use helpers for pulling pricing limits * cleanup more things * cleanup * restore environment ts file * remove unused files * fix(team-management): fix team management UI, consolidate components * use session data instead of subscription data in settings navigation * remove unused code * fix UI for enterprise plans * added enterprise plan support * progress * billing state machine * split overage and base into separate invoices * fix badge logic --------- Co-authored-by: waleedlatif1 <walif6@gmail.com>
1 parent 7cc4574 commit 56543da

File tree

85 files changed

+9017
-5064
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

85 files changed

+9017
-5064
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { toNextJsHandler } from 'better-auth/next-js'
2+
import { auth } from '@/lib/auth'
3+
4+
export const dynamic = 'force-dynamic'
5+
6+
// Handle Stripe webhooks through better-auth
7+
export const { GET, POST } = toNextJsHandler(auth.handler)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { and, eq } from 'drizzle-orm'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { requireStripeClient } from '@/lib/billing/stripe-client'
5+
import { env } from '@/lib/env'
6+
import { createLogger } from '@/lib/logs/console/logger'
7+
import { db } from '@/db'
8+
import { subscription as subscriptionTable, user } from '@/db/schema'
9+
10+
const logger = createLogger('BillingPortal')
11+
12+
export async function POST(request: NextRequest) {
13+
const session = await getSession()
14+
15+
try {
16+
if (!session?.user?.id) {
17+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
18+
}
19+
20+
const body = await request.json().catch(() => ({}))
21+
const context: 'user' | 'organization' =
22+
body?.context === 'organization' ? 'organization' : 'user'
23+
const organizationId: string | undefined = body?.organizationId || undefined
24+
const returnUrl: string =
25+
body?.returnUrl || `${env.NEXT_PUBLIC_APP_URL}/workspace?billing=updated`
26+
27+
const stripe = requireStripeClient()
28+
29+
let stripeCustomerId: string | null = null
30+
31+
if (context === 'organization') {
32+
if (!organizationId) {
33+
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
34+
}
35+
36+
const rows = await db
37+
.select({ customer: subscriptionTable.stripeCustomerId })
38+
.from(subscriptionTable)
39+
.where(
40+
and(
41+
eq(subscriptionTable.referenceId, organizationId),
42+
eq(subscriptionTable.status, 'active')
43+
)
44+
)
45+
.limit(1)
46+
47+
stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
48+
} else {
49+
const rows = await db
50+
.select({ customer: user.stripeCustomerId })
51+
.from(user)
52+
.where(eq(user.id, session.user.id))
53+
.limit(1)
54+
55+
stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
56+
}
57+
58+
if (!stripeCustomerId) {
59+
logger.error('Stripe customer not found for portal session', {
60+
context,
61+
organizationId,
62+
userId: session.user.id,
63+
})
64+
return NextResponse.json({ error: 'Stripe customer not found' }, { status: 404 })
65+
}
66+
67+
const portal = await stripe.billingPortal.sessions.create({
68+
customer: stripeCustomerId,
69+
return_url: returnUrl,
70+
})
71+
72+
return NextResponse.json({ url: portal.url })
73+
} catch (error) {
74+
logger.error('Failed to create billing portal session', { error })
75+
return NextResponse.json({ error: 'Failed to create billing portal session' }, { status: 500 })
76+
}
77+
}

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
55
import { getOrganizationBillingData } from '@/lib/billing/core/organization-billing'
66
import { createLogger } from '@/lib/logs/console/logger'
77
import { db } from '@/db'
8-
import { member } from '@/db/schema'
8+
import { member, userStats } from '@/db/schema'
99

1010
const logger = createLogger('UnifiedBillingAPI')
1111

@@ -45,6 +45,16 @@ export async function GET(request: NextRequest) {
4545
if (context === 'user') {
4646
// Get user billing (may include organization if they're part of one)
4747
billingData = await getSimplifiedBillingSummary(session.user.id, contextId || undefined)
48+
// Attach billingBlocked status for the current user
49+
const stats = await db
50+
.select({ blocked: userStats.billingBlocked })
51+
.from(userStats)
52+
.where(eq(userStats.userId, session.user.id))
53+
.limit(1)
54+
billingData = {
55+
...billingData,
56+
billingBlocked: stats.length > 0 ? !!stats[0].blocked : false,
57+
}
4858
} else {
4959
// Get user role in organization for permission checks first
5060
const memberRecord = await db
@@ -78,8 +88,10 @@ export async function GET(request: NextRequest) {
7888
subscriptionStatus: rawBillingData.subscriptionStatus,
7989
totalSeats: rawBillingData.totalSeats,
8090
usedSeats: rawBillingData.usedSeats,
91+
seatsCount: rawBillingData.seatsCount,
8192
totalCurrentUsage: rawBillingData.totalCurrentUsage,
8293
totalUsageLimit: rawBillingData.totalUsageLimit,
94+
minimumBillingAmount: rawBillingData.minimumBillingAmount,
8395
averageUsagePerMember: rawBillingData.averageUsagePerMember,
8496
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
8597
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
@@ -92,11 +104,25 @@ export async function GET(request: NextRequest) {
92104

93105
const userRole = memberRecord[0].role
94106

107+
// Include the requesting user's blocked flag as well so UI can reflect it
108+
const stats = await db
109+
.select({ blocked: userStats.billingBlocked })
110+
.from(userStats)
111+
.where(eq(userStats.userId, session.user.id))
112+
.limit(1)
113+
114+
// Merge blocked flag into data for convenience
115+
billingData = {
116+
...billingData,
117+
billingBlocked: stats.length > 0 ? !!stats[0].blocked : false,
118+
}
119+
95120
return NextResponse.json({
96121
success: true,
97122
context,
98123
data: billingData,
99124
userRole,
125+
billingBlocked: billingData.billingBlocked,
100126
})
101127
}
102128

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 27 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -115,52 +115,34 @@ export async function POST(req: NextRequest) {
115115
const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
116116

117117
if (userStatsRecords.length === 0) {
118-
// Create new user stats record (same logic as ExecutionLogger)
119-
await db.insert(userStats).values({
120-
id: crypto.randomUUID(),
121-
userId: userId,
122-
totalManualExecutions: 0,
123-
totalApiCalls: 0,
124-
totalWebhookTriggers: 0,
125-
totalScheduledExecutions: 0,
126-
totalChatExecutions: 0,
127-
totalTokensUsed: totalTokens,
128-
totalCost: costToStore.toString(),
129-
currentPeriodCost: costToStore.toString(),
130-
// Copilot usage tracking
131-
totalCopilotCost: costToStore.toString(),
132-
totalCopilotTokens: totalTokens,
133-
totalCopilotCalls: 1,
134-
lastActive: new Date(),
135-
})
136-
137-
logger.info(`[${requestId}] Created new user stats record`, {
138-
userId,
139-
totalCost: costToStore,
140-
totalTokens,
141-
})
142-
} else {
143-
// Update existing user stats record (same logic as ExecutionLogger)
144-
const updateFields = {
145-
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
146-
totalCost: sql`total_cost + ${costToStore}`,
147-
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
148-
// Copilot usage tracking increments
149-
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
150-
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
151-
totalCopilotCalls: sql`total_copilot_calls + 1`,
152-
totalApiCalls: sql`total_api_calls`,
153-
lastActive: new Date(),
154-
}
155-
156-
await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
157-
158-
logger.info(`[${requestId}] Updated user stats record`, {
159-
userId,
160-
addedCost: costToStore,
161-
addedTokens: totalTokens,
162-
})
118+
logger.error(
119+
`[${requestId}] User stats record not found - should be created during onboarding`,
120+
{
121+
userId,
122+
}
123+
)
124+
return NextResponse.json({ error: 'User stats record not found' }, { status: 500 })
163125
}
126+
// Update existing user stats record (same logic as ExecutionLogger)
127+
const updateFields = {
128+
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
129+
totalCost: sql`total_cost + ${costToStore}`,
130+
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
131+
// Copilot usage tracking increments
132+
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
133+
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
134+
totalCopilotCalls: sql`total_copilot_calls + 1`,
135+
totalApiCalls: sql`total_api_calls`,
136+
lastActive: new Date(),
137+
}
138+
139+
await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
140+
141+
logger.info(`[${requestId}] Updated user stats record`, {
142+
userId,
143+
addedCost: costToStore,
144+
addedTokens: totalTokens,
145+
})
164146

165147
const duration = Date.now() - startTime
166148

apps/sim/app/api/billing/webhooks/stripe/route.ts

Lines changed: 0 additions & 116 deletions
This file was deleted.

apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ export async function GET(
8181
.select({
8282
currentPeriodCost: userStats.currentPeriodCost,
8383
currentUsageLimit: userStats.currentUsageLimit,
84-
usageLimitSetBy: userStats.usageLimitSetBy,
8584
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
8685
lastPeriodCost: userStats.lastPeriodCost,
8786
})

apps/sim/app/api/organizations/[id]/members/route.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
7575
userEmail: user.email,
7676
currentPeriodCost: userStats.currentPeriodCost,
7777
currentUsageLimit: userStats.currentUsageLimit,
78-
usageLimitSetBy: userStats.usageLimitSetBy,
7978
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
8079
})
8180
.from(member)

0 commit comments

Comments
 (0)