Skip to content

Commit e5080fe

Browse files
waleedlatif1Vikhyath Mondreti
andauthored
feat(billing): add comprehensive usage-based billing system (#625)
* feat(billing): add comprehensive usage-based billing system - Complete billing infrastructure with subscription management - Usage tracking and limits for organizations - Team management with role-based permissions - CRON jobs for automated billing and cleanup - Stripe integration for payments and invoicing - Email notifications for billing events - Organization-based workspace management - API endpoints for billing operations * fix tests, standardize datetime logic * add lazy init for stripe client, similar to s3 * cleanup * ack PR comments * fixed build * convert everything to UTC * add delete subscription functionality using better auth * fix lint * fix linter error * remove invoice emails since it is natively managed via stripe * fix build --------- Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
1 parent 529fd44 commit e5080fe

File tree

105 files changed

+12457
-5019
lines changed

Some content is hidden

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

105 files changed

+12457
-5019
lines changed

apps/sim/.env.example

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,3 @@ ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate
1515
# RESEND_API_KEY= # Uncomment and add your key from https://resend.com to send actual emails
1616
# If left commented out, emails will be logged to console instead
1717

18-
# Freestyle API Key (Required for sandboxed code execution for functions/custom-tools)
19-
# FREESTYLE_API_KEY= # Uncomment and add your key from https://docs.freestyle.sh/Getting-Started/run

apps/sim/app/(landing)/components/waitlist-form.tsx

Lines changed: 0 additions & 116 deletions
This file was deleted.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { verifyCronAuth } from '@/lib/auth/internal'
3+
import { processDailyBillingCheck } from '@/lib/billing/core/billing'
4+
import { createLogger } from '@/lib/logs/console-logger'
5+
6+
const logger = createLogger('DailyBillingCron')
7+
8+
/**
9+
* Daily billing CRON job endpoint that checks individual billing periods
10+
*/
11+
export async function POST(request: NextRequest) {
12+
try {
13+
const authError = verifyCronAuth(request, 'daily billing check')
14+
if (authError) {
15+
return authError
16+
}
17+
18+
logger.info('Starting daily billing check cron job')
19+
20+
const startTime = Date.now()
21+
22+
// Process overage billing for users and organizations with periods ending today
23+
const result = await processDailyBillingCheck()
24+
25+
const duration = Date.now() - startTime
26+
27+
if (result.success) {
28+
logger.info('Daily billing check completed successfully', {
29+
processedUsers: result.processedUsers,
30+
processedOrganizations: result.processedOrganizations,
31+
totalChargedAmount: result.totalChargedAmount,
32+
duration: `${duration}ms`,
33+
})
34+
35+
return NextResponse.json({
36+
success: true,
37+
summary: {
38+
processedUsers: result.processedUsers,
39+
processedOrganizations: result.processedOrganizations,
40+
totalChargedAmount: result.totalChargedAmount,
41+
duration: `${duration}ms`,
42+
},
43+
})
44+
}
45+
46+
logger.error('Daily billing check completed with errors', {
47+
processedUsers: result.processedUsers,
48+
processedOrganizations: result.processedOrganizations,
49+
totalChargedAmount: result.totalChargedAmount,
50+
errorCount: result.errors.length,
51+
errors: result.errors,
52+
duration: `${duration}ms`,
53+
})
54+
55+
return NextResponse.json(
56+
{
57+
success: false,
58+
summary: {
59+
processedUsers: result.processedUsers,
60+
processedOrganizations: result.processedOrganizations,
61+
totalChargedAmount: result.totalChargedAmount,
62+
errorCount: result.errors.length,
63+
duration: `${duration}ms`,
64+
},
65+
errors: result.errors,
66+
},
67+
{ status: 500 }
68+
)
69+
} catch (error) {
70+
logger.error('Fatal error in monthly billing cron job', { error })
71+
72+
return NextResponse.json(
73+
{
74+
success: false,
75+
error: 'Internal server error during daily billing check',
76+
details: error instanceof Error ? error.message : 'Unknown error',
77+
},
78+
{ status: 500 }
79+
)
80+
}
81+
}
82+
83+
/**
84+
* GET endpoint for manual testing and health checks
85+
*/
86+
export async function GET(request: NextRequest) {
87+
try {
88+
const authError = verifyCronAuth(request, 'daily billing check health check')
89+
if (authError) {
90+
return authError
91+
}
92+
93+
return NextResponse.json({
94+
status: 'ready',
95+
message:
96+
'Daily billing check cron job is ready to process users and organizations with periods ending today',
97+
currentDate: new Date().toISOString().split('T')[0],
98+
})
99+
} catch (error) {
100+
logger.error('Error in billing health check', { error })
101+
return NextResponse.json(
102+
{
103+
status: 'error',
104+
error: error instanceof Error ? error.message : 'Unknown error',
105+
},
106+
{ status: 500 }
107+
)
108+
}
109+
}

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

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { and, eq } from 'drizzle-orm'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
5+
import { getOrganizationBillingData } from '@/lib/billing/core/organization-billing'
6+
import { createLogger } from '@/lib/logs/console-logger'
7+
import { db } from '@/db'
8+
import { member } from '@/db/schema'
9+
10+
const logger = createLogger('UnifiedBillingAPI')
11+
12+
/**
13+
* Unified Billing Endpoint
14+
*/
15+
export async function GET(request: NextRequest) {
16+
const session = await getSession()
17+
18+
try {
19+
if (!session?.user?.id) {
20+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21+
}
22+
23+
const { searchParams } = new URL(request.url)
24+
const context = searchParams.get('context') || 'user'
25+
const contextId = searchParams.get('id')
26+
27+
// Validate context parameter
28+
if (!['user', 'organization'].includes(context)) {
29+
return NextResponse.json(
30+
{ error: 'Invalid context. Must be "user" or "organization"' },
31+
{ status: 400 }
32+
)
33+
}
34+
35+
// For organization context, require contextId
36+
if (context === 'organization' && !contextId) {
37+
return NextResponse.json(
38+
{ error: 'Organization ID is required when context=organization' },
39+
{ status: 400 }
40+
)
41+
}
42+
43+
let billingData
44+
45+
if (context === 'user') {
46+
// Get user billing (may include organization if they're part of one)
47+
billingData = await getSimplifiedBillingSummary(session.user.id, contextId || undefined)
48+
} else {
49+
// Get user role in organization for permission checks first
50+
const memberRecord = await db
51+
.select({ role: member.role })
52+
.from(member)
53+
.where(and(eq(member.organizationId, contextId!), eq(member.userId, session.user.id)))
54+
.limit(1)
55+
56+
if (memberRecord.length === 0) {
57+
return NextResponse.json(
58+
{ error: 'Access denied - not a member of this organization' },
59+
{ status: 403 }
60+
)
61+
}
62+
63+
// Get organization-specific billing
64+
const rawBillingData = await getOrganizationBillingData(contextId!)
65+
66+
if (!rawBillingData) {
67+
return NextResponse.json(
68+
{ error: 'Organization not found or access denied' },
69+
{ status: 404 }
70+
)
71+
}
72+
73+
// Transform data to match component expectations
74+
billingData = {
75+
organizationId: rawBillingData.organizationId,
76+
organizationName: rawBillingData.organizationName,
77+
subscriptionPlan: rawBillingData.subscriptionPlan,
78+
subscriptionStatus: rawBillingData.subscriptionStatus,
79+
totalSeats: rawBillingData.totalSeats,
80+
usedSeats: rawBillingData.usedSeats,
81+
totalCurrentUsage: rawBillingData.totalCurrentUsage,
82+
totalUsageLimit: rawBillingData.totalUsageLimit,
83+
averageUsagePerMember: rawBillingData.averageUsagePerMember,
84+
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
85+
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
86+
members: rawBillingData.members.map((member) => ({
87+
...member,
88+
joinedAt: member.joinedAt.toISOString(),
89+
lastActive: member.lastActive?.toISOString() || null,
90+
})),
91+
}
92+
93+
const userRole = memberRecord[0].role
94+
95+
return NextResponse.json({
96+
success: true,
97+
context,
98+
data: billingData,
99+
userRole,
100+
})
101+
}
102+
103+
return NextResponse.json({
104+
success: true,
105+
context,
106+
data: billingData,
107+
})
108+
} catch (error) {
109+
logger.error('Failed to get billing data', {
110+
userId: session?.user?.id,
111+
error,
112+
})
113+
114+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
115+
}
116+
}

0 commit comments

Comments
 (0)