mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-01 10:14:56 -05:00
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>
This commit is contained in:
462
apps/sim/scripts/test-billing-suite.ts
Normal file
462
apps/sim/scripts/test-billing-suite.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { config } from 'dotenv'
|
||||
import { eq, like } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { processDailyBillingCheck } from '@/lib/billing/core/billing'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { member, organization, subscription, user, userStats } from '@/db/schema'
|
||||
|
||||
// Load environment variables
|
||||
config()
|
||||
|
||||
const logger = createLogger('BillingTestSuite')
|
||||
|
||||
interface TestUser {
|
||||
id: string
|
||||
email: string
|
||||
stripeCustomerId: string
|
||||
plan: string
|
||||
usage: number
|
||||
overage: number
|
||||
}
|
||||
|
||||
interface TestOrg {
|
||||
id: string
|
||||
name: string
|
||||
stripeCustomerId: string
|
||||
plan: string
|
||||
seats: number
|
||||
memberCount: number
|
||||
totalUsage: number
|
||||
overage: number
|
||||
}
|
||||
|
||||
interface TestResults {
|
||||
users: TestUser[]
|
||||
organizations: TestOrg[]
|
||||
billingResults: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive billing test suite
|
||||
* Run with: bun run test:billing:suite
|
||||
*/
|
||||
async function runBillingTestSuite(): Promise<TestResults> {
|
||||
logger.info('🚀 Starting comprehensive billing test suite...')
|
||||
|
||||
const results: TestResults = {
|
||||
users: [],
|
||||
organizations: [],
|
||||
billingResults: null,
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Create test users for each scenario
|
||||
logger.info('\n📋 Creating test users...')
|
||||
|
||||
// Free user (no overage billing)
|
||||
const freeUser = await createTestUser('free', 5) // $5 usage on free plan
|
||||
results.users.push(freeUser)
|
||||
|
||||
// Pro user with no overage
|
||||
const proUserNoOverage = await createTestUser('pro', 15) // $15 usage < $20 base
|
||||
results.users.push(proUserNoOverage)
|
||||
|
||||
// Pro user with overage
|
||||
const proUserWithOverage = await createTestUser('pro', 35) // $35 usage > $20 base = $15 overage
|
||||
results.users.push(proUserWithOverage)
|
||||
|
||||
// Pro user with high overage
|
||||
const proUserHighOverage = await createTestUser('pro', 100) // $100 usage = $80 overage
|
||||
results.users.push(proUserHighOverage)
|
||||
|
||||
// 2. Create test organizations
|
||||
logger.info('\n🏢 Creating test organizations...')
|
||||
|
||||
// Team with no overage (2 seats, 3 members, low usage)
|
||||
const teamNoOverage = await createTestOrganization('team', 2, 3, 150) // 3 members, $150 total < $200 base (2 seats × $100)
|
||||
results.organizations.push(teamNoOverage)
|
||||
|
||||
// Team with overage (2 seats, 3 members, high usage)
|
||||
const teamWithOverage = await createTestOrganization('team', 2, 3, 350) // 3 members, $350 total > $200 base = $150 overage
|
||||
results.organizations.push(teamWithOverage)
|
||||
|
||||
// Enterprise with overage (5 seats, 8 members, high usage)
|
||||
const enterpriseWithOverage = await createTestOrganization('enterprise', 5, 8, 2000) // 8 members, $2000 total > $1500 base (5 seats × $300) = $500 overage
|
||||
results.organizations.push(enterpriseWithOverage)
|
||||
|
||||
// 3. Display test data summary
|
||||
logger.info('\n📊 Test Data Summary:')
|
||||
logger.info('===================')
|
||||
|
||||
logger.info('\n👤 Individual Users:')
|
||||
for (const user of results.users) {
|
||||
logger.info(` ${user.plan.toUpperCase()} - ${user.email}`)
|
||||
logger.info(` Usage: $${user.usage} | Overage: $${user.overage}`)
|
||||
logger.info(` Customer: ${user.stripeCustomerId}`)
|
||||
}
|
||||
|
||||
logger.info('\n🏢 Organizations:')
|
||||
for (const org of results.organizations) {
|
||||
logger.info(` ${org.plan.toUpperCase()} - ${org.name}`)
|
||||
logger.info(
|
||||
` Seats: ${org.seats} | Members: ${org.memberCount} | Usage: $${org.totalUsage} | Overage: $${org.overage}`
|
||||
)
|
||||
logger.info(` Customer: ${org.stripeCustomerId}`)
|
||||
}
|
||||
|
||||
// 4. Wait for user confirmation
|
||||
logger.info('\n⏸️ Test data created. Ready to run billing CRON?')
|
||||
logger.info(' Press Ctrl+C to cancel, or wait 5 seconds to continue...')
|
||||
await sleep(5000)
|
||||
|
||||
// 5. Run the daily billing CRON
|
||||
logger.info('\n🔄 Running daily billing CRON...')
|
||||
const billingResult = await processDailyBillingCheck()
|
||||
results.billingResults = billingResult
|
||||
|
||||
// 6. Display billing results
|
||||
logger.info('\n💰 Billing Results:')
|
||||
logger.info('==================')
|
||||
logger.info(`✅ Success: ${billingResult.success}`)
|
||||
logger.info(`👤 Users processed: ${billingResult.processedUsers}`)
|
||||
logger.info(`🏢 Organizations processed: ${billingResult.processedOrganizations}`)
|
||||
logger.info(`💵 Total charged: $${billingResult.totalChargedAmount}`)
|
||||
|
||||
if (billingResult.errors.length > 0) {
|
||||
logger.error('❌ Errors:', billingResult.errors)
|
||||
}
|
||||
|
||||
// 7. Verify results in Stripe
|
||||
logger.info('\n🔍 Verifying in Stripe...')
|
||||
await verifyStripeResults(results)
|
||||
|
||||
logger.info('\n✅ Test suite completed successfully!')
|
||||
logger.info('\n📝 Next steps:')
|
||||
logger.info('1. Check your Stripe Dashboard for invoices')
|
||||
logger.info('2. Monitor webhook events in your listener')
|
||||
logger.info('3. Check for email notifications (if in live mode)')
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
logger.error('Test suite failed', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function createTestUser(plan: 'free' | 'pro', usageAmount: number): Promise<TestUser> {
|
||||
const stripe = requireStripeClient()
|
||||
const userId = nanoid()
|
||||
const email = `test-${plan}-${Date.now()}@example.com`
|
||||
|
||||
// Create Stripe customer
|
||||
const stripeCustomer = await stripe.customers.create({
|
||||
email,
|
||||
metadata: {
|
||||
userId,
|
||||
testUser: 'true',
|
||||
plan,
|
||||
},
|
||||
})
|
||||
|
||||
// Add payment method
|
||||
const paymentMethod = await stripe.paymentMethods.create({
|
||||
type: 'card',
|
||||
card: { token: 'tok_visa' },
|
||||
})
|
||||
|
||||
await stripe.paymentMethods.attach(paymentMethod.id, {
|
||||
customer: stripeCustomer.id,
|
||||
})
|
||||
|
||||
await stripe.customers.update(stripeCustomer.id, {
|
||||
invoice_settings: {
|
||||
default_payment_method: paymentMethod.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Create user in database
|
||||
await db.insert(user).values({
|
||||
id: userId,
|
||||
email,
|
||||
name: `Test ${plan.toUpperCase()} User`,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Create subscription
|
||||
const periodEnd = new Date()
|
||||
periodEnd.setUTCHours(23, 59, 59, 999) // End of today
|
||||
|
||||
await db.insert(subscription).values({
|
||||
id: nanoid(),
|
||||
plan,
|
||||
referenceId: userId,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
stripeSubscriptionId: `sub_test_${nanoid()}`,
|
||||
status: 'active',
|
||||
periodStart: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // 30 days ago
|
||||
periodEnd,
|
||||
seats: 1,
|
||||
})
|
||||
|
||||
// Create user stats
|
||||
await db.insert(userStats).values({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
currentPeriodCost: usageAmount.toString(),
|
||||
billingPeriodEnd: periodEnd,
|
||||
currentUsageLimit: (usageAmount + 10).toString(), // Some headroom
|
||||
})
|
||||
|
||||
const basePrice = plan === 'pro' ? 20 : 0
|
||||
const overage = Math.max(0, usageAmount - basePrice)
|
||||
|
||||
logger.info(`✅ Created ${plan} user`, {
|
||||
email,
|
||||
usage: `$${usageAmount}`,
|
||||
overage: `$${overage}`,
|
||||
})
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
email,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
plan,
|
||||
usage: usageAmount,
|
||||
overage,
|
||||
}
|
||||
}
|
||||
|
||||
async function createTestOrganization(
|
||||
plan: 'team' | 'enterprise',
|
||||
seats: number,
|
||||
memberCount: number,
|
||||
totalUsage: number
|
||||
): Promise<TestOrg> {
|
||||
const stripe = requireStripeClient()
|
||||
const orgId = nanoid()
|
||||
const orgName = `Test ${plan.toUpperCase()} Org ${Date.now()}`
|
||||
|
||||
// Create Stripe customer for org FIRST
|
||||
const stripeCustomer = await stripe.customers.create({
|
||||
email: `billing-${orgId}@example.com`,
|
||||
name: orgName,
|
||||
metadata: {
|
||||
organizationId: orgId,
|
||||
testOrg: 'true',
|
||||
plan,
|
||||
},
|
||||
})
|
||||
|
||||
// Add payment method
|
||||
const paymentMethod = await stripe.paymentMethods.create({
|
||||
type: 'card',
|
||||
card: { token: 'tok_visa' },
|
||||
})
|
||||
|
||||
await stripe.paymentMethods.attach(paymentMethod.id, {
|
||||
customer: stripeCustomer.id,
|
||||
})
|
||||
|
||||
await stripe.customers.update(stripeCustomer.id, {
|
||||
invoice_settings: {
|
||||
default_payment_method: paymentMethod.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Create organization in DB with Stripe customer ID in metadata
|
||||
await db.insert(organization).values({
|
||||
id: orgId,
|
||||
name: orgName,
|
||||
slug: `test-${plan}-org-${Date.now()}`,
|
||||
metadata: { stripeCustomerId: stripeCustomer.id }, // Store Stripe customer ID in metadata
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Create subscription
|
||||
const periodEnd = new Date()
|
||||
periodEnd.setUTCHours(23, 59, 59, 999) // End of today
|
||||
|
||||
// Add metadata for enterprise plans
|
||||
const metadata =
|
||||
plan === 'enterprise'
|
||||
? { perSeatAllowance: 500, totalAllowance: 5000 } // Enterprise gets $500 per seat or $5000 total
|
||||
: {}
|
||||
|
||||
await db.insert(subscription).values({
|
||||
id: nanoid(),
|
||||
plan,
|
||||
referenceId: orgId,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
stripeSubscriptionId: `sub_test_${nanoid()}`,
|
||||
status: 'active',
|
||||
periodStart: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // 30 days ago
|
||||
periodEnd,
|
||||
seats,
|
||||
metadata,
|
||||
})
|
||||
|
||||
// Create members with usage
|
||||
const usagePerMember = Math.floor(totalUsage / memberCount)
|
||||
for (let i = 0; i < memberCount; i++) {
|
||||
const memberId = nanoid()
|
||||
const isOwner = i === 0
|
||||
|
||||
// Create user
|
||||
await db.insert(user).values({
|
||||
id: memberId,
|
||||
email: `member-${i + 1}-${orgId}@example.com`,
|
||||
name: `Member ${i + 1}`,
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Add to organization
|
||||
await db.insert(member).values({
|
||||
id: nanoid(),
|
||||
userId: memberId,
|
||||
organizationId: orgId,
|
||||
role: isOwner ? 'owner' : 'member',
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Create user stats
|
||||
await db.insert(userStats).values({
|
||||
id: nanoid(),
|
||||
userId: memberId,
|
||||
currentPeriodCost: usagePerMember.toString(),
|
||||
billingPeriodEnd: periodEnd,
|
||||
currentUsageLimit: (usagePerMember + 50).toString(),
|
||||
})
|
||||
}
|
||||
|
||||
const basePricePerSeat = plan === 'team' ? 100 : 300
|
||||
const baseTotal = seats * basePricePerSeat
|
||||
const overage = Math.max(0, totalUsage - baseTotal)
|
||||
|
||||
logger.info(`✅ Created ${plan} organization`, {
|
||||
name: orgName,
|
||||
seats,
|
||||
members: memberCount,
|
||||
usage: `$${totalUsage}`,
|
||||
overage: `$${overage}`,
|
||||
})
|
||||
|
||||
return {
|
||||
id: orgId,
|
||||
name: orgName,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
plan,
|
||||
seats,
|
||||
memberCount,
|
||||
totalUsage,
|
||||
overage,
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyStripeResults(results: TestResults) {
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
logger.info('\n📋 Stripe Verification:')
|
||||
|
||||
// Check for recent invoices
|
||||
const recentInvoices = await stripe.invoices.list({
|
||||
limit: 20,
|
||||
created: {
|
||||
gte: Math.floor(Date.now() / 1000) - 300, // Last 5 minutes
|
||||
},
|
||||
})
|
||||
|
||||
const testInvoices = recentInvoices.data.filter((inv) => inv.metadata?.type === 'overage_billing')
|
||||
|
||||
logger.info(`Found ${testInvoices.length} overage invoices created`)
|
||||
|
||||
for (const invoice of testInvoices) {
|
||||
const customerType = invoice.metadata?.organizationId ? 'Organization' : 'User'
|
||||
logger.info(` ${customerType} Invoice: ${invoice.number || invoice.id}`)
|
||||
logger.info(` Amount: $${invoice.amount_due / 100}`)
|
||||
logger.info(` Status: ${invoice.status}`)
|
||||
logger.info(` Customer: ${invoice.customer}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
async function cleanupTestData() {
|
||||
logger.info('\n🧹 Cleaning up test data...')
|
||||
|
||||
try {
|
||||
// Find all test users
|
||||
const testUsers = await db.select().from(user).where(like(user.email, 'test-%'))
|
||||
|
||||
// Find all test organizations
|
||||
const testOrgs = await db.select().from(organization).where(like(organization.name, 'Test %'))
|
||||
|
||||
logger.info(
|
||||
`Found ${testUsers.length} test users and ${testOrgs.length} test organizations to clean up`
|
||||
)
|
||||
|
||||
// Clean up users
|
||||
for (const testUser of testUsers) {
|
||||
await db.delete(userStats).where(eq(userStats.userId, testUser.id))
|
||||
await db.delete(member).where(eq(member.userId, testUser.id))
|
||||
await db.delete(subscription).where(eq(subscription.referenceId, testUser.id))
|
||||
await db.delete(user).where(eq(user.id, testUser.id))
|
||||
}
|
||||
|
||||
// Clean up organizations
|
||||
for (const org of testOrgs) {
|
||||
await db.delete(member).where(eq(member.organizationId, org.id))
|
||||
await db.delete(subscription).where(eq(subscription.referenceId, org.id))
|
||||
await db.delete(organization).where(eq(organization.id, org.id))
|
||||
}
|
||||
|
||||
logger.info('✅ Cleanup completed')
|
||||
} catch (error) {
|
||||
logger.error('Cleanup failed', { error })
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
if (args.includes('--cleanup')) {
|
||||
await cleanupTestData()
|
||||
return
|
||||
}
|
||||
|
||||
if (args.includes('--help')) {
|
||||
logger.info('Billing Test Suite')
|
||||
logger.info('==================')
|
||||
logger.info('Usage: bun run test:billing:suite [options]')
|
||||
logger.info('')
|
||||
logger.info('Options:')
|
||||
logger.info(' --cleanup Clean up all test data')
|
||||
logger.info(' --help Show this help message')
|
||||
logger.info('')
|
||||
logger.info('This script will:')
|
||||
logger.info('1. Create test users (free, pro with/without overage)')
|
||||
logger.info('2. Create test organizations (team, enterprise)')
|
||||
logger.info('3. Run the daily billing CRON')
|
||||
logger.info('4. Verify results in Stripe')
|
||||
return
|
||||
}
|
||||
|
||||
await runBillingTestSuite()
|
||||
}
|
||||
|
||||
// Run the suite
|
||||
main().catch((error) => {
|
||||
logger.error('Test suite failed', { error })
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user