mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
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>
This commit is contained in:
committed by
GitHub
parent
7cc4574913
commit
56543dafb4
7
apps/sim/app/api/auth/webhook/stripe/route.ts
Normal file
7
apps/sim/app/api/auth/webhook/stripe/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { toNextJsHandler } from 'better-auth/next-js'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Handle Stripe webhooks through better-auth
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler)
|
||||
77
apps/sim/app/api/billing/portal/route.ts
Normal file
77
apps/sim/app/api/billing/portal/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { subscription as subscriptionTable, user } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('BillingPortal')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
|
||||
try {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const context: 'user' | 'organization' =
|
||||
body?.context === 'organization' ? 'organization' : 'user'
|
||||
const organizationId: string | undefined = body?.organizationId || undefined
|
||||
const returnUrl: string =
|
||||
body?.returnUrl || `${env.NEXT_PUBLIC_APP_URL}/workspace?billing=updated`
|
||||
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
let stripeCustomerId: string | null = null
|
||||
|
||||
if (context === 'organization') {
|
||||
if (!organizationId) {
|
||||
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ customer: subscriptionTable.stripeCustomerId })
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, organizationId),
|
||||
eq(subscriptionTable.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
|
||||
} else {
|
||||
const rows = await db
|
||||
.select({ customer: user.stripeCustomerId })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
|
||||
}
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
logger.error('Stripe customer not found for portal session', {
|
||||
context,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
return NextResponse.json({ error: 'Stripe customer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const portal = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: returnUrl,
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: portal.url })
|
||||
} catch (error) {
|
||||
logger.error('Failed to create billing portal session', { error })
|
||||
return NextResponse.json({ error: 'Failed to create billing portal session' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
|
||||
import { getOrganizationBillingData } from '@/lib/billing/core/organization-billing'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member } from '@/db/schema'
|
||||
import { member, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('UnifiedBillingAPI')
|
||||
|
||||
@@ -45,6 +45,16 @@ export async function GET(request: NextRequest) {
|
||||
if (context === 'user') {
|
||||
// Get user billing (may include organization if they're part of one)
|
||||
billingData = await getSimplifiedBillingSummary(session.user.id, contextId || undefined)
|
||||
// Attach billingBlocked status for the current user
|
||||
const stats = await db
|
||||
.select({ blocked: userStats.billingBlocked })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, session.user.id))
|
||||
.limit(1)
|
||||
billingData = {
|
||||
...billingData,
|
||||
billingBlocked: stats.length > 0 ? !!stats[0].blocked : false,
|
||||
}
|
||||
} else {
|
||||
// Get user role in organization for permission checks first
|
||||
const memberRecord = await db
|
||||
@@ -78,8 +88,10 @@ export async function GET(request: NextRequest) {
|
||||
subscriptionStatus: rawBillingData.subscriptionStatus,
|
||||
totalSeats: rawBillingData.totalSeats,
|
||||
usedSeats: rawBillingData.usedSeats,
|
||||
seatsCount: rawBillingData.seatsCount,
|
||||
totalCurrentUsage: rawBillingData.totalCurrentUsage,
|
||||
totalUsageLimit: rawBillingData.totalUsageLimit,
|
||||
minimumBillingAmount: rawBillingData.minimumBillingAmount,
|
||||
averageUsagePerMember: rawBillingData.averageUsagePerMember,
|
||||
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
|
||||
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
|
||||
@@ -92,11 +104,25 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const userRole = memberRecord[0].role
|
||||
|
||||
// Include the requesting user's blocked flag as well so UI can reflect it
|
||||
const stats = await db
|
||||
.select({ blocked: userStats.billingBlocked })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
// Merge blocked flag into data for convenience
|
||||
billingData = {
|
||||
...billingData,
|
||||
billingBlocked: stats.length > 0 ? !!stats[0].blocked : false,
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
context,
|
||||
data: billingData,
|
||||
userRole,
|
||||
billingBlocked: billingData.billingBlocked,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -115,52 +115,34 @@ export async function POST(req: NextRequest) {
|
||||
const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
|
||||
if (userStatsRecords.length === 0) {
|
||||
// Create new user stats record (same logic as ExecutionLogger)
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: userId,
|
||||
totalManualExecutions: 0,
|
||||
totalApiCalls: 0,
|
||||
totalWebhookTriggers: 0,
|
||||
totalScheduledExecutions: 0,
|
||||
totalChatExecutions: 0,
|
||||
totalTokensUsed: totalTokens,
|
||||
totalCost: costToStore.toString(),
|
||||
currentPeriodCost: costToStore.toString(),
|
||||
// Copilot usage tracking
|
||||
totalCopilotCost: costToStore.toString(),
|
||||
totalCopilotTokens: totalTokens,
|
||||
totalCopilotCalls: 1,
|
||||
lastActive: new Date(),
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Created new user stats record`, {
|
||||
userId,
|
||||
totalCost: costToStore,
|
||||
totalTokens,
|
||||
})
|
||||
} else {
|
||||
// Update existing user stats record (same logic as ExecutionLogger)
|
||||
const updateFields = {
|
||||
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
|
||||
// Copilot usage tracking increments
|
||||
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
|
||||
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
|
||||
totalCopilotCalls: sql`total_copilot_calls + 1`,
|
||||
totalApiCalls: sql`total_api_calls`,
|
||||
lastActive: new Date(),
|
||||
}
|
||||
|
||||
await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info(`[${requestId}] Updated user stats record`, {
|
||||
userId,
|
||||
addedCost: costToStore,
|
||||
addedTokens: totalTokens,
|
||||
})
|
||||
logger.error(
|
||||
`[${requestId}] User stats record not found - should be created during onboarding`,
|
||||
{
|
||||
userId,
|
||||
}
|
||||
)
|
||||
return NextResponse.json({ error: 'User stats record not found' }, { status: 500 })
|
||||
}
|
||||
// Update existing user stats record (same logic as ExecutionLogger)
|
||||
const updateFields = {
|
||||
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
|
||||
// Copilot usage tracking increments
|
||||
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
|
||||
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
|
||||
totalCopilotCalls: sql`total_copilot_calls + 1`,
|
||||
totalApiCalls: sql`total_api_calls`,
|
||||
lastActive: new Date(),
|
||||
}
|
||||
|
||||
await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info(`[${requestId}] Updated user stats record`, {
|
||||
userId,
|
||||
addedCost: costToStore,
|
||||
addedTokens: totalTokens,
|
||||
})
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type Stripe from 'stripe'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { handleInvoiceWebhook } from '@/lib/billing/webhooks/stripe-invoice-webhooks'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('StripeInvoiceWebhook')
|
||||
|
||||
/**
|
||||
* Stripe billing webhook endpoint for invoice-related events
|
||||
* Endpoint: /api/billing/webhooks/stripe
|
||||
* Handles: invoice.payment_succeeded, invoice.payment_failed, invoice.finalized
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.text()
|
||||
const headersList = await headers()
|
||||
const signature = headersList.get('stripe-signature')
|
||||
|
||||
if (!signature) {
|
||||
logger.error('Missing Stripe signature header')
|
||||
return NextResponse.json({ error: 'Missing Stripe signature' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!env.STRIPE_BILLING_WEBHOOK_SECRET) {
|
||||
logger.error('Missing Stripe webhook secret configuration')
|
||||
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Check if Stripe client is available
|
||||
let stripe
|
||||
try {
|
||||
stripe = requireStripeClient()
|
||||
} catch (stripeError) {
|
||||
logger.error('Stripe client not available for webhook processing', {
|
||||
error: stripeError,
|
||||
})
|
||||
return NextResponse.json({ error: 'Stripe client not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
let event: Stripe.Event
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(body, signature, env.STRIPE_BILLING_WEBHOOK_SECRET)
|
||||
} catch (signatureError) {
|
||||
logger.error('Invalid Stripe webhook signature', {
|
||||
error: signatureError,
|
||||
signature,
|
||||
})
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info('Received Stripe invoice webhook', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
})
|
||||
|
||||
// Handle specific invoice events
|
||||
const supportedEvents = [
|
||||
'invoice.payment_succeeded',
|
||||
'invoice.payment_failed',
|
||||
'invoice.finalized',
|
||||
]
|
||||
|
||||
if (supportedEvents.includes(event.type)) {
|
||||
try {
|
||||
await handleInvoiceWebhook(event)
|
||||
|
||||
logger.info('Successfully processed invoice webhook', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
})
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
} catch (processingError) {
|
||||
logger.error('Failed to process invoice webhook', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
error: processingError,
|
||||
})
|
||||
|
||||
// Return 500 to tell Stripe to retry the webhook
|
||||
return NextResponse.json({ error: 'Failed to process webhook' }, { status: 500 })
|
||||
}
|
||||
} else {
|
||||
// Not a supported invoice event, ignore
|
||||
logger.info('Ignoring unsupported webhook event', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
supportedEvents,
|
||||
})
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Fatal error in invoice webhook handler', {
|
||||
error,
|
||||
url: request.url,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET endpoint for webhook health checks
|
||||
*/
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
webhook: 'stripe-invoices',
|
||||
events: ['invoice.payment_succeeded', 'invoice.payment_failed', 'invoice.finalized'],
|
||||
})
|
||||
}
|
||||
@@ -81,7 +81,6 @@ export async function GET(
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
usageLimitSetBy: userStats.usageLimitSetBy,
|
||||
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
|
||||
lastPeriodCost: userStats.lastPeriodCost,
|
||||
})
|
||||
|
||||
@@ -75,7 +75,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
userEmail: user.email,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
usageLimitSetBy: userStats.usageLimitSetBy,
|
||||
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
|
||||
})
|
||||
.from(member)
|
||||
|
||||
73
apps/sim/app/api/organizations/route.ts
Normal file
73
apps/sim/app/api/organizations/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createOrganizationForTeamPlan } from '@/lib/billing/organization'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CreateTeamOrganization')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized - no active session' }, { status: 401 })
|
||||
}
|
||||
|
||||
const user = session.user
|
||||
|
||||
// Parse request body for optional name and slug
|
||||
let organizationName = user.name
|
||||
let organizationSlug: string | undefined
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
if (body.name && typeof body.name === 'string') {
|
||||
organizationName = body.name
|
||||
}
|
||||
if (body.slug && typeof body.slug === 'string') {
|
||||
organizationSlug = body.slug
|
||||
}
|
||||
} catch {
|
||||
// If no body or invalid JSON, use defaults
|
||||
}
|
||||
|
||||
logger.info('Creating organization for team plan', {
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
organizationName,
|
||||
organizationSlug,
|
||||
})
|
||||
|
||||
// Create organization and make user the owner/admin
|
||||
const organizationId = await createOrganizationForTeamPlan(
|
||||
user.id,
|
||||
organizationName || undefined,
|
||||
user.email,
|
||||
organizationSlug
|
||||
)
|
||||
|
||||
logger.info('Successfully created organization for team plan', {
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
organizationId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to create organization for team plan', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to create organization',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUserUsageLimitInfo, updateUserUsageLimit } from '@/lib/billing'
|
||||
import { updateMemberUsageLimit } from '@/lib/billing/core/organization-billing'
|
||||
import { getOrganizationBillingData } from '@/lib/billing/core/organization-billing'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { isOrganizationOwnerOrAdmin } from '@/lib/permissions/utils'
|
||||
|
||||
@@ -9,7 +9,7 @@ const logger = createLogger('UnifiedUsageLimitsAPI')
|
||||
|
||||
/**
|
||||
* Unified Usage Limits Endpoint
|
||||
* GET/PUT /api/usage-limits?context=user|member&userId=<id>&organizationId=<id>
|
||||
* GET/PUT /api/usage-limits?context=user|organization&userId=<id>&organizationId=<id>
|
||||
*
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -26,40 +26,13 @@ export async function GET(request: NextRequest) {
|
||||
const organizationId = searchParams.get('organizationId')
|
||||
|
||||
// Validate context
|
||||
if (!['user', 'member'].includes(context)) {
|
||||
if (!['user', 'organization'].includes(context)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid context. Must be "user" or "member"' },
|
||||
{ error: 'Invalid context. Must be "user" or "organization"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// For member context, require organizationId and check permissions
|
||||
if (context === 'member') {
|
||||
if (!organizationId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization ID is required when context=member' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if the current user has permission to view member usage info
|
||||
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId)
|
||||
if (!hasPermission) {
|
||||
logger.warn('Unauthorized attempt to view member usage info', {
|
||||
requesterId: session.user.id,
|
||||
targetUserId: userId,
|
||||
organizationId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Permission denied. Only organization owners and admins can view member usage information',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// For user context, ensure they can only view their own info
|
||||
if (context === 'user' && userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
@@ -69,6 +42,23 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Get usage limit info
|
||||
if (context === 'organization') {
|
||||
if (!organizationId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization ID is required when context=organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const org = await getOrganizationBillingData(organizationId)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
context,
|
||||
userId,
|
||||
organizationId,
|
||||
data: org,
|
||||
})
|
||||
}
|
||||
|
||||
const usageLimitInfo = await getUserUsageLimitInfo(userId)
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -96,12 +86,11 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const context = searchParams.get('context') || 'user'
|
||||
const userId = searchParams.get('userId') || session.user.id
|
||||
const organizationId = searchParams.get('organizationId')
|
||||
|
||||
const { limit } = await request.json()
|
||||
const body = await request.json()
|
||||
const limit = body?.limit
|
||||
const context = body?.context || 'user'
|
||||
const organizationId = body?.organizationId
|
||||
const userId = session.user.id
|
||||
|
||||
if (typeof limit !== 'number' || limit < 0) {
|
||||
return NextResponse.json(
|
||||
@@ -110,52 +99,42 @@ export async function PUT(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
if (!['user', 'organization'].includes(context)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid context. Must be "user" or "organization"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (context === 'user') {
|
||||
// Update user's own usage limit
|
||||
if (userId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Cannot update other users' limits" }, { status: 403 })
|
||||
}
|
||||
|
||||
await updateUserUsageLimit(userId, limit)
|
||||
} else if (context === 'member') {
|
||||
// Update organization member's usage limit
|
||||
} else if (context === 'organization') {
|
||||
// context === 'organization'
|
||||
if (!organizationId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization ID is required when context=member' },
|
||||
{ error: 'Organization ID is required when context=organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if the current user has permission to update member limits
|
||||
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId)
|
||||
if (!hasPermission) {
|
||||
logger.warn('Unauthorized attempt to update member usage limit', {
|
||||
adminUserId: session.user.id,
|
||||
targetUserId: userId,
|
||||
organizationId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Permission denied. Only organization owners and admins can update member usage limits',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
logger.info('Authorized member usage limit update', {
|
||||
adminUserId: session.user.id,
|
||||
targetUserId: userId,
|
||||
organizationId,
|
||||
newLimit: limit,
|
||||
})
|
||||
|
||||
await updateMemberUsageLimit(organizationId, userId, limit, session.user.id)
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid context. Must be "user" or "member"' },
|
||||
{ status: 400 }
|
||||
// Use the dedicated function to update org usage limit
|
||||
const { updateOrganizationUsageLimit } = await import(
|
||||
'@/lib/billing/core/organization-billing'
|
||||
)
|
||||
const result = await updateOrganizationUsageLimit(organizationId, limit)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await getOrganizationBillingData(organizationId)
|
||||
return NextResponse.json({ success: true, context, userId, organizationId, data: updated })
|
||||
}
|
||||
|
||||
// Return updated limit info
|
||||
|
||||
38
apps/sim/app/api/usage/check/route.ts
Normal file
38
apps/sim/app/api/usage/check/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('UsageCheckAPI')
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const session = await getSession()
|
||||
try {
|
||||
const userId = session?.user?.id
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const result = await checkServerSideUsageLimits(userId)
|
||||
// Normalize to client usage shape
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
percentUsed:
|
||||
result.limit > 0
|
||||
? Math.min(Math.floor((result.currentUsage / result.limit) * 100), 100)
|
||||
: 0,
|
||||
isWarning:
|
||||
result.limit > 0
|
||||
? (result.currentUsage / result.limit) * 100 >= 80 &&
|
||||
(result.currentUsage / result.limit) * 100 < 100
|
||||
: false,
|
||||
isExceeded: result.isExceeded,
|
||||
currentUsage: result.currentUsage,
|
||||
limit: result.limit,
|
||||
message: result.message,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed usage check', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -320,23 +320,23 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use subscription store to get usage data
|
||||
const { getUsage, refresh } = useSubscriptionStore.getState()
|
||||
|
||||
// Force refresh if requested
|
||||
if (forceRefresh) {
|
||||
await refresh()
|
||||
// Primary: call server-side usage check to mirror backend enforcement
|
||||
const res = await fetch('/api/usage/check', { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
const payload = await res.json()
|
||||
const usage = payload?.data
|
||||
// Update cache
|
||||
usageDataCache = { data: usage, timestamp: now, expirationMs: usageDataCache.expirationMs }
|
||||
return usage
|
||||
}
|
||||
|
||||
// Fallback: use store if API not available
|
||||
const { getUsage, refresh } = useSubscriptionStore.getState()
|
||||
if (forceRefresh) await refresh()
|
||||
const usage = getUsage()
|
||||
|
||||
// Update cache
|
||||
usageDataCache = {
|
||||
data: usage,
|
||||
timestamp: now,
|
||||
expirationMs: usageDataCache.expirationMs,
|
||||
}
|
||||
|
||||
usageDataCache = { data: usage, timestamp: now, expirationMs: usageDataCache.expirationMs }
|
||||
return usage
|
||||
} catch (error) {
|
||||
logger.error('Error checking usage limits:', { error })
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
@@ -106,9 +105,6 @@ export function SettingsNavigation({
|
||||
onSectionChange,
|
||||
hasOrganization,
|
||||
}: SettingsNavigationProps) {
|
||||
const { getSubscriptionStatus } = useSubscriptionStore()
|
||||
const subscription = getSubscriptionStatus()
|
||||
|
||||
const navigationItems = allNavigationItems.filter((item) => {
|
||||
if (item.id === 'copilot' && !isHosted) {
|
||||
return false
|
||||
@@ -117,8 +113,8 @@ export function SettingsNavigation({
|
||||
return false
|
||||
}
|
||||
|
||||
// Hide team tab if user doesn't have team or enterprise subscription
|
||||
if (item.requiresTeam && !subscription.isTeam && !subscription.isEnterprise) {
|
||||
// Hide team tab if user doesn't have an active organization
|
||||
if (item.requiresTeam && !hasOrganization) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge, Progress } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const GRADIENT_BADGE_STYLES =
|
||||
'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer'
|
||||
|
||||
interface UsageHeaderProps {
|
||||
title: string
|
||||
gradientTitle?: boolean
|
||||
showBadge?: boolean
|
||||
badgeText?: string
|
||||
onBadgeClick?: () => void
|
||||
rightContent?: ReactNode
|
||||
current: number
|
||||
limit: number
|
||||
progressValue?: number
|
||||
seatsText?: string
|
||||
isBlocked?: boolean
|
||||
onResolvePayment?: () => void
|
||||
status?: 'ok' | 'warning' | 'exceeded' | 'blocked'
|
||||
percentUsed?: number
|
||||
}
|
||||
|
||||
export function UsageHeader({
|
||||
title,
|
||||
gradientTitle = false,
|
||||
showBadge = false,
|
||||
badgeText,
|
||||
onBadgeClick,
|
||||
rightContent,
|
||||
current,
|
||||
limit,
|
||||
progressValue,
|
||||
seatsText,
|
||||
isBlocked,
|
||||
onResolvePayment,
|
||||
status,
|
||||
percentUsed,
|
||||
}: UsageHeaderProps) {
|
||||
const progress = progressValue ?? (limit > 0 ? Math.min((current / limit) * 100, 100) : 0)
|
||||
|
||||
return (
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium text-sm',
|
||||
gradientTitle
|
||||
? 'gradient-text bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
|
||||
: 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{showBadge && badgeText ? (
|
||||
<Badge className={GRADIENT_BADGE_STYLES} onClick={onBadgeClick}>
|
||||
{badgeText}
|
||||
</Badge>
|
||||
) : null}
|
||||
{seatsText ? (
|
||||
<span className='text-muted-foreground text-xs'>({seatsText})</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-xs tabular-nums'>
|
||||
{isBlocked ? (
|
||||
<span className='text-muted-foreground'>Payment required</span>
|
||||
) : (
|
||||
<>
|
||||
<span className='text-muted-foreground'>${current.toFixed(2)}</span>
|
||||
<span className='text-muted-foreground'>/</span>
|
||||
{rightContent ?? <span className='text-muted-foreground'>${limit}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress value={isBlocked ? 100 : progress} className='h-2' />
|
||||
|
||||
{isBlocked && (
|
||||
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>
|
||||
<span className='text-destructive text-xs'>
|
||||
Payment failed. Please update your payment method.
|
||||
</span>
|
||||
{onResolvePayment && (
|
||||
<button
|
||||
type='button'
|
||||
className='font-medium text-destructive text-xs underline underline-offset-2'
|
||||
onClick={onResolvePayment}
|
||||
>
|
||||
Resolve payment
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isBlocked && status === 'exceeded' && (
|
||||
<div className='rounded-[6px] bg-amber-900/10 px-2 py-1'>
|
||||
<span className='text-amber-600 text-xs'>
|
||||
Usage limit exceeded. Increase your limit to continue.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isBlocked && status === 'warning' && (
|
||||
<div className='rounded-[6px] bg-yellow-900/10 px-2 py-1'>
|
||||
<span className='text-xs text-yellow-600'>
|
||||
{typeof percentUsed === 'number' ? `${percentUsed}%` : '80%+'} of your limit used.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession, useSubscription } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -38,8 +39,9 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
|
||||
const { data: session } = useSession()
|
||||
const betterAuthSubscription = useSubscription()
|
||||
const { activeOrganization } = useOrganizationStore()
|
||||
const { getSubscriptionStatus } = useSubscriptionStore()
|
||||
const { activeOrganization, loadOrganizationSubscription, refreshOrganization } =
|
||||
useOrganizationStore()
|
||||
const { getSubscriptionStatus, refresh } = useSubscriptionStore()
|
||||
|
||||
// Clear error after 3 seconds
|
||||
useEffect(() => {
|
||||
@@ -67,27 +69,43 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
const activeOrgId = activeOrganization?.id
|
||||
|
||||
let referenceId = session.user.id
|
||||
let subscriptionId: string | undefined
|
||||
|
||||
if (subscriptionStatus.isTeam && activeOrgId) {
|
||||
referenceId = activeOrgId
|
||||
// Get subscription ID for team/enterprise
|
||||
const orgSubscription = useOrganizationStore.getState().subscriptionData
|
||||
subscriptionId = orgSubscription?.id
|
||||
}
|
||||
|
||||
logger.info('Canceling subscription', {
|
||||
referenceId,
|
||||
subscriptionId,
|
||||
isTeam: subscriptionStatus.isTeam,
|
||||
activeOrgId,
|
||||
})
|
||||
|
||||
const result = await betterAuthSubscription.cancel({
|
||||
returnUrl: window.location.href,
|
||||
if (!betterAuthSubscription.cancel) {
|
||||
throw new Error('Subscription management not available')
|
||||
}
|
||||
|
||||
const returnUrl = window.location.origin + window.location.pathname.split('/w/')[0]
|
||||
|
||||
const cancelParams: any = {
|
||||
returnUrl,
|
||||
referenceId,
|
||||
})
|
||||
}
|
||||
|
||||
if (subscriptionId) {
|
||||
cancelParams.subscriptionId = subscriptionId
|
||||
}
|
||||
|
||||
const result = await betterAuthSubscription.cancel(cancelParams)
|
||||
|
||||
if (result && 'error' in result && result.error) {
|
||||
setError(result.error.message || 'Failed to cancel subscription')
|
||||
logger.error('Failed to cancel subscription via Better Auth', { error: result.error })
|
||||
} else {
|
||||
// Better Auth cancel redirects to Stripe Billing Portal
|
||||
// So if we reach here without error, the redirect should happen
|
||||
logger.info('Redirecting to Stripe Billing Portal for cancellation')
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -98,6 +116,49 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
const handleKeep = async () => {
|
||||
if (!session?.user?.id) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const subscriptionStatus = getSubscriptionStatus()
|
||||
const activeOrgId = activeOrganization?.id
|
||||
|
||||
// For team/enterprise plans, get the subscription ID from organization store
|
||||
if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) {
|
||||
const orgSubscription = useOrganizationStore.getState().subscriptionData
|
||||
|
||||
if (orgSubscription?.id && orgSubscription?.cancelAtPeriodEnd) {
|
||||
// Restore the organization subscription
|
||||
if (!betterAuthSubscription.restore) {
|
||||
throw new Error('Subscription restore not available')
|
||||
}
|
||||
|
||||
const result = await betterAuthSubscription.restore({
|
||||
referenceId: activeOrgId,
|
||||
subscriptionId: orgSubscription.id,
|
||||
})
|
||||
logger.info('Organization subscription restored successfully', result)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh state and close
|
||||
await refresh()
|
||||
if (activeOrgId) {
|
||||
await loadOrganizationSubscription(activeOrgId)
|
||||
await refreshOrganization().catch(() => {})
|
||||
}
|
||||
setIsDialogOpen(false)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to keep subscription'
|
||||
setError(errorMessage)
|
||||
logger.error('Failed to keep subscription', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
const getPeriodEndDate = () => {
|
||||
return subscriptionData?.periodEnd || null
|
||||
}
|
||||
@@ -127,14 +188,25 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
|
||||
const periodEndDate = getPeriodEndDate()
|
||||
|
||||
// Check if subscription is set to cancel at period end
|
||||
const isCancelAtPeriodEnd = (() => {
|
||||
const subscriptionStatus = getSubscriptionStatus()
|
||||
if (subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) {
|
||||
return useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd === true
|
||||
}
|
||||
return false
|
||||
})()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<span className='font-medium text-sm'>Manage Subscription</span>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
You'll keep access until {formatDate(periodEndDate)}
|
||||
</p>
|
||||
{isCancelAtPeriodEnd && (
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
You'll keep access until {formatDate(periodEndDate)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -154,39 +226,78 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel {subscription.plan} subscription?</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
{isCancelAtPeriodEnd ? 'Manage' : 'Cancel'} {subscription.plan} subscription?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You'll be redirected to Stripe to manage your subscription. You'll keep access until{' '}
|
||||
{formatDate(periodEndDate)}, then downgrade to free plan.
|
||||
{isCancelAtPeriodEnd
|
||||
? 'Your subscription is set to cancel at the end of the billing period. You can reactivate it or manage other settings.'
|
||||
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
|
||||
periodEndDate
|
||||
)}, then downgrade to free plan.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className='py-2'>
|
||||
<div className='rounded-[8px] bg-muted/50 p-3 text-sm'>
|
||||
<ul className='space-y-1 text-muted-foreground text-xs'>
|
||||
<li>• Keep all features until {formatDate(periodEndDate)}</li>
|
||||
<li>• No more charges</li>
|
||||
<li>• Data preserved</li>
|
||||
<li>• Can reactivate anytime</li>
|
||||
</ul>
|
||||
{!isCancelAtPeriodEnd && (
|
||||
<div className='py-2'>
|
||||
<div className='rounded-[8px] bg-muted/50 p-3 text-sm'>
|
||||
<ul className='space-y-1 text-muted-foreground text-xs'>
|
||||
<li>• Keep all features until {formatDate(periodEndDate)}</li>
|
||||
<li>• No more charges</li>
|
||||
<li>• Data preserved</li>
|
||||
<li>• Can reactivate anytime</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel
|
||||
className='h-9 w-full rounded-[8px]'
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
onClick={handleKeep}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Keep Subscription
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleCancel}
|
||||
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Redirecting...' : 'Continue'}
|
||||
</AlertDialogAction>
|
||||
|
||||
{(() => {
|
||||
const subscriptionStatus = getSubscriptionStatus()
|
||||
if (
|
||||
subscriptionStatus.isPaid &&
|
||||
(activeOrganization?.id
|
||||
? useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd
|
||||
: false)
|
||||
) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<AlertDialogAction
|
||||
disabled
|
||||
className='h-9 w-full cursor-not-allowed rounded-[8px] bg-muted text-muted-foreground opacity-50'
|
||||
>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
<p>Subscription will be cancelled at end of billing period</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<AlertDialogAction
|
||||
onClick={handleCancel}
|
||||
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Redirecting...' : 'Continue'}
|
||||
</AlertDialogAction>
|
||||
)
|
||||
})()}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -15,6 +15,8 @@ interface UsageLimitProps {
|
||||
canEdit: boolean
|
||||
minimumLimit: number
|
||||
onLimitUpdated?: (newLimit: number) => void
|
||||
context?: 'user' | 'organization'
|
||||
organizationId?: string
|
||||
}
|
||||
|
||||
export interface UsageLimitRef {
|
||||
@@ -22,7 +24,18 @@ export interface UsageLimitRef {
|
||||
}
|
||||
|
||||
export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
({ currentLimit, currentUsage, canEdit, minimumLimit, onLimitUpdated }, ref) => {
|
||||
(
|
||||
{
|
||||
currentLimit,
|
||||
currentUsage,
|
||||
canEdit,
|
||||
minimumLimit,
|
||||
onLimitUpdated,
|
||||
context = 'user',
|
||||
organizationId,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [inputValue, setInputValue] = useState(currentLimit.toString())
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
@@ -95,10 +108,28 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
const result = await updateUsageLimit(newLimit)
|
||||
if (context === 'organization') {
|
||||
if (!organizationId) {
|
||||
throw new Error('Organization ID is required')
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update limit')
|
||||
const response = await fetch('/api/usage-limits', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ context: 'organization', organizationId, limit: newLimit }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update limit')
|
||||
}
|
||||
} else {
|
||||
const result = await updateUsageLimit(newLimit)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update limit')
|
||||
}
|
||||
}
|
||||
|
||||
setInputValue(newLimit.toString())
|
||||
@@ -158,19 +189,19 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
handleSubmit()
|
||||
}}
|
||||
className={cn(
|
||||
'w-[3ch] border-0 bg-transparent p-0 text-xs tabular-nums',
|
||||
'border-0 bg-transparent p-0 text-xs tabular-nums',
|
||||
'outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
||||
hasError && 'text-red-500'
|
||||
)}
|
||||
min={minimumLimit}
|
||||
max='999'
|
||||
step='1'
|
||||
disabled={isSaving}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
style={{ width: `${Math.max(3, inputValue.length)}ch` }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -36,7 +36,7 @@ export function getSubscriptionPermissions(
|
||||
canUpgradeToTeam: isFree || (isPro && !isTeam),
|
||||
canViewEnterprise: !isEnterprise && !(isTeam && !isTeamAdmin), // Don't show to enterprise users or team members
|
||||
canManageTeam: isTeam && isTeamAdmin,
|
||||
canEditUsageLimit: (isFree || (isPro && !isTeam)) && !isEnterprise, // Free users see upgrade badge, Pro (non-team) users see pencil
|
||||
canEditUsageLimit: (isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin)) && !isEnterprise, // Free users see upgrade badge, Pro (non-team) users and team admins see pencil
|
||||
canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel
|
||||
showTeamMemberView: isTeam && !isTeamAdmin,
|
||||
showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Badge, Progress, Skeleton } from '@/components/ui'
|
||||
import { useSession, useSubscription } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { UsageHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header'
|
||||
import {
|
||||
CancelSubscription,
|
||||
PlanCard,
|
||||
@@ -23,10 +24,6 @@ import {
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
|
||||
// Logger
|
||||
const logger = createLogger('Subscription')
|
||||
|
||||
// Constants
|
||||
const CONSTANTS = {
|
||||
UPGRADE_ERROR_TIMEOUT: 3000, // 3 seconds
|
||||
TYPEFORM_ENTERPRISE_URL: 'https://form.typeform.com/to/jqCO12pF',
|
||||
@@ -35,13 +32,11 @@ const CONSTANTS = {
|
||||
INITIAL_TEAM_SEATS: 1,
|
||||
} as const
|
||||
|
||||
// Styles
|
||||
const STYLES = {
|
||||
GRADIENT_BADGE:
|
||||
'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer',
|
||||
} as const
|
||||
|
||||
// Types
|
||||
type TargetPlan = 'pro' | 'team'
|
||||
|
||||
interface SubscriptionProps {
|
||||
@@ -49,7 +44,7 @@ interface SubscriptionProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton component for subscription loading state
|
||||
* Skeleton component for subscription loading state.
|
||||
*/
|
||||
function SubscriptionSkeleton() {
|
||||
return (
|
||||
@@ -170,7 +165,6 @@ function SubscriptionSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
const formatPlanName = (plan: string): string => plan.charAt(0).toUpperCase() + plan.slice(1)
|
||||
|
||||
/**
|
||||
@@ -179,7 +173,7 @@ const formatPlanName = (plan: string): string => plan.charAt(0).toUpperCase() +
|
||||
*/
|
||||
export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const { data: session } = useSession()
|
||||
const betterAuthSubscription = useSubscription()
|
||||
const { handleUpgrade } = useSubscriptionUpgrade()
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
@@ -258,7 +252,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
|
||||
// UI state computed values
|
||||
const showBadge = permissions.canEditUsageLimit && !permissions.showTeamMemberView
|
||||
const badgeText = subscription.isFree ? 'Upgrade' : 'Add'
|
||||
const badgeText = subscription.isFree ? 'Upgrade' : 'Increase Limit'
|
||||
|
||||
const handleBadgeClick = () => {
|
||||
if (subscription.isFree) {
|
||||
@@ -268,50 +262,15 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpgrade = useCallback(
|
||||
const handleUpgradeWithErrorHandling = useCallback(
|
||||
async (targetPlan: TargetPlan) => {
|
||||
if (!session?.user?.id) return
|
||||
|
||||
const { subscriptionData } = useSubscriptionStore.getState()
|
||||
const currentSubscriptionId = subscriptionData?.stripeSubscriptionId
|
||||
|
||||
let referenceId = session.user.id
|
||||
if (subscription.isTeam && activeOrgId) {
|
||||
referenceId = activeOrgId
|
||||
}
|
||||
|
||||
const currentUrl = `${window.location.origin}${window.location.pathname}`
|
||||
|
||||
try {
|
||||
const upgradeParams = {
|
||||
plan: targetPlan,
|
||||
referenceId,
|
||||
successUrl: currentUrl,
|
||||
cancelUrl: currentUrl,
|
||||
...(targetPlan === 'team' && { seats: CONSTANTS.INITIAL_TEAM_SEATS }),
|
||||
} as const
|
||||
|
||||
// Add subscriptionId for existing subscriptions to ensure proper plan switching
|
||||
const finalParams = currentSubscriptionId
|
||||
? { ...upgradeParams, subscriptionId: currentSubscriptionId }
|
||||
: upgradeParams
|
||||
|
||||
logger.info(
|
||||
currentSubscriptionId ? 'Upgrading existing subscription' : 'Creating new subscription',
|
||||
{
|
||||
targetPlan,
|
||||
currentSubscriptionId,
|
||||
referenceId,
|
||||
}
|
||||
)
|
||||
|
||||
await betterAuthSubscription.upgrade(finalParams)
|
||||
await handleUpgrade(targetPlan)
|
||||
} catch (error) {
|
||||
logger.error('Failed to initiate subscription upgrade:', error)
|
||||
alert('Failed to initiate upgrade. Please try again or contact support.')
|
||||
alert(error instanceof Error ? error.message : 'Unknown error occurred')
|
||||
}
|
||||
},
|
||||
[session?.user?.id, subscription.isTeam, activeOrgId, betterAuthSubscription]
|
||||
[handleUpgrade]
|
||||
)
|
||||
|
||||
const renderPlanCard = useCallback(
|
||||
@@ -328,7 +287,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
priceSubtext='/month'
|
||||
features={PRO_PLAN_FEATURES}
|
||||
buttonText={subscription.isFree ? 'Upgrade' : 'Upgrade to Pro'}
|
||||
onButtonClick={() => handleUpgrade('pro')}
|
||||
onButtonClick={() => handleUpgradeWithErrorHandling('pro')}
|
||||
isError={upgradeError === 'pro'}
|
||||
layout={layout}
|
||||
/>
|
||||
@@ -343,7 +302,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
priceSubtext='/month'
|
||||
features={TEAM_PLAN_FEATURES}
|
||||
buttonText={subscription.isFree ? 'Upgrade' : 'Upgrade to Team'}
|
||||
onButtonClick={() => handleUpgrade('team')}
|
||||
onButtonClick={() => handleUpgradeWithErrorHandling('team')}
|
||||
isError={upgradeError === 'team'}
|
||||
layout={layout}
|
||||
/>
|
||||
@@ -383,63 +342,81 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
<div className='flex flex-col gap-2'>
|
||||
{/* Current Plan & Usage Overview - Styled like usage-indicator */}
|
||||
<div className='mb-2'>
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<div className='space-y-2'>
|
||||
{/* Plan and usage info */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium text-sm',
|
||||
subscription.isFree
|
||||
? 'text-foreground'
|
||||
: 'gradient-text bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
|
||||
)}
|
||||
>
|
||||
{formatPlanName(subscription.plan)}
|
||||
</span>
|
||||
{showBadge && (
|
||||
<Badge
|
||||
className={STYLES.GRADIENT_BADGE}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleBadgeClick()
|
||||
}}
|
||||
>
|
||||
{badgeText}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Team seats info for admins */}
|
||||
{permissions.canManageTeam && (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
({organizationBillingData?.totalSeats || subscription.seats || 1} seats)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground'>${usage.current.toFixed(2)}</span>
|
||||
<span className='text-muted-foreground'>/</span>
|
||||
{!subscription.isFree &&
|
||||
(permissions.canEditUsageLimit ||
|
||||
permissions.showTeamMemberView ||
|
||||
subscription.isEnterprise) ? (
|
||||
<UsageLimit
|
||||
ref={usageLimitRef}
|
||||
currentLimit={usageLimitData?.currentLimit || usage.limit}
|
||||
currentUsage={usage.current}
|
||||
canEdit={permissions.canEditUsageLimit && !subscription.isEnterprise}
|
||||
minimumLimit={usageLimitData?.minimumLimit || (subscription.isPro ? 20 : 40)}
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground'>${usage.limit}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<Progress value={Math.min(usage.percentUsed, 100)} className='h-2' />
|
||||
</div>
|
||||
</div>
|
||||
<UsageHeader
|
||||
title={formatPlanName(subscription.plan)}
|
||||
gradientTitle={!subscription.isFree}
|
||||
showBadge={showBadge}
|
||||
badgeText={badgeText}
|
||||
onBadgeClick={handleBadgeClick}
|
||||
seatsText={
|
||||
permissions.canManageTeam
|
||||
? `${organizationBillingData?.totalSeats || subscription.seats || 1} seats`
|
||||
: undefined
|
||||
}
|
||||
current={usage.current}
|
||||
limit={
|
||||
!subscription.isFree &&
|
||||
(permissions.canEditUsageLimit ||
|
||||
permissions.showTeamMemberView ||
|
||||
subscription.isEnterprise)
|
||||
? usage.current // placeholder; rightContent will render UsageLimit
|
||||
: usage.limit
|
||||
}
|
||||
isBlocked={Boolean(subscriptionData?.billingBlocked)}
|
||||
status={billingStatus === 'unknown' ? 'ok' : billingStatus}
|
||||
percentUsed={Math.round(usage.percentUsed)}
|
||||
onResolvePayment={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/billing/portal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
context:
|
||||
subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user',
|
||||
organizationId: activeOrgId,
|
||||
returnUrl: `${window.location.origin}/workspace?billing=updated`,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok || !data?.url)
|
||||
throw new Error(data?.error || 'Failed to start billing portal')
|
||||
window.location.href = data.url
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to open billing portal')
|
||||
}
|
||||
}}
|
||||
rightContent={
|
||||
!subscription.isFree &&
|
||||
(permissions.canEditUsageLimit ||
|
||||
permissions.showTeamMemberView ||
|
||||
subscription.isEnterprise) ? (
|
||||
<UsageLimit
|
||||
ref={usageLimitRef}
|
||||
currentLimit={
|
||||
subscription.isTeam && isTeamAdmin
|
||||
? organizationBillingData?.totalUsageLimit || usage.limit
|
||||
: usageLimitData?.currentLimit || usage.limit
|
||||
}
|
||||
currentUsage={usage.current}
|
||||
canEdit={permissions.canEditUsageLimit && !subscription.isEnterprise}
|
||||
minimumLimit={
|
||||
subscription.isTeam && isTeamAdmin
|
||||
? organizationBillingData?.minimumBillingAmount ||
|
||||
(subscription.isPro ? 20 : 40)
|
||||
: usageLimitData?.minimumLimit || (subscription.isPro ? 20 : 40)
|
||||
}
|
||||
context={subscription.isTeam && isTeamAdmin ? 'organization' : 'user'}
|
||||
organizationId={subscription.isTeam && isTeamAdmin ? activeOrgId : undefined}
|
||||
onLimitUpdated={async () => {
|
||||
if (subscription.isTeam && isTeamAdmin && activeOrgId) {
|
||||
await loadOrganizationBillingData(activeOrgId, true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
progressValue={Math.min(Math.round(usage.percentUsed), 100)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team Member Notice */}
|
||||
@@ -498,6 +475,16 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Billing Date */}
|
||||
{subscription.isPaid && subscriptionData?.periodEnd && (
|
||||
<div className='mt-4 flex items-center justify-between'>
|
||||
<span className='font-medium text-sm'>Next Billing Date</span>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{new Date(subscriptionData.periodEnd).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subscription.isEnterprise && (
|
||||
<div className='text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
export { MemberInvitationCard } from './member-invitation-card'
|
||||
export { MemberLimit } from './member-limit'
|
||||
export { NoOrganizationView } from './no-organization-view'
|
||||
export { OrganizationCreationDialog } from './organization-creation-dialog'
|
||||
export { OrganizationSettingsTab } from './organization-settings-tab'
|
||||
export { PendingInvitationsList } from './pending-invitations-list'
|
||||
export { RemoveMemberDialog } from './remove-member-dialog'
|
||||
export { TeamMembersList } from './team-members-list'
|
||||
export { TeamSeats } from './team-seats'
|
||||
export { TeamSeatsOverview } from './team-seats-overview'
|
||||
export { TeamUsage } from './team-usage'
|
||||
export { MemberInvitationCard } from './member-invitation-card/member-invitation-card'
|
||||
export { NoOrganizationView } from './no-organization-view/no-organization-view'
|
||||
export { RemoveMemberDialog } from './remove-member-dialog/remove-member-dialog'
|
||||
export { TeamMembers } from './team-members/team-members'
|
||||
export { TeamSeats } from './team-seats/team-seats'
|
||||
export { TeamSeatsOverview } from './team-seats-overview/team-seats-overview'
|
||||
export { TeamUsage } from './team-usage/team-usage'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { MemberInvitationCard } from './member-invitation-card'
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { CheckCircle, ChevronDown, PlusCircle } from 'lucide-react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { CheckCircle } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { quickValidateEmail } from '@/lib/email/validation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PermissionType = 'read' | 'write' | 'admin'
|
||||
@@ -31,10 +31,7 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex overflow-hidden rounded-md border border-input bg-background shadow-sm',
|
||||
className
|
||||
)}
|
||||
className={cn('inline-flex rounded-[12px] border border-input bg-background', className)}
|
||||
>
|
||||
{permissionOptions.map((option, index) => (
|
||||
<button
|
||||
@@ -44,11 +41,12 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
|
||||
disabled={disabled}
|
||||
title={option.description}
|
||||
className={cn(
|
||||
'relative px-3 py-1.5 font-medium text-sm transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
'px-2.5 py-1.5 font-medium text-xs transition-colors focus:outline-none',
|
||||
'first:rounded-l-[11px] last:rounded-r-[11px]',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
value === option.value
|
||||
? 'z-10 bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:z-20 hover:bg-muted/50 hover:text-foreground',
|
||||
? 'bg-foreground text-background'
|
||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
|
||||
index > 0 && 'border-input border-l'
|
||||
)}
|
||||
>
|
||||
@@ -74,6 +72,8 @@ interface MemberInvitationCardProps {
|
||||
onLoadUserWorkspaces: () => Promise<void>
|
||||
onWorkspaceToggle: (workspaceId: string, permission: string) => void
|
||||
inviteSuccess: boolean
|
||||
availableSeats?: number
|
||||
maxSeats?: number
|
||||
}
|
||||
|
||||
function ButtonSkeleton() {
|
||||
@@ -94,106 +94,137 @@ export function MemberInvitationCard({
|
||||
onLoadUserWorkspaces,
|
||||
onWorkspaceToggle,
|
||||
inviteSuccess,
|
||||
availableSeats = 0,
|
||||
maxSeats = 0,
|
||||
}: MemberInvitationCardProps) {
|
||||
const selectedCount = selectedWorkspaces.length
|
||||
const hasAvailableSeats = availableSeats > 0
|
||||
const [emailError, setEmailError] = useState<string>('')
|
||||
|
||||
// Email validation function using existing lib
|
||||
const validateEmailInput = (email: string) => {
|
||||
if (!email.trim()) {
|
||||
setEmailError('')
|
||||
return
|
||||
}
|
||||
|
||||
const validation = quickValidateEmail(email.trim())
|
||||
if (!validation.isValid) {
|
||||
setEmailError(validation.reason || 'Please enter a valid email address')
|
||||
} else {
|
||||
setEmailError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setInviteEmail(value)
|
||||
// Clear error when user starts typing again
|
||||
if (emailError) {
|
||||
setEmailError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleInviteClick = () => {
|
||||
// Validate email before proceeding
|
||||
if (inviteEmail.trim()) {
|
||||
validateEmailInput(inviteEmail)
|
||||
const validation = quickValidateEmail(inviteEmail.trim())
|
||||
if (!validation.isValid) {
|
||||
return // Don't proceed if validation fails
|
||||
}
|
||||
}
|
||||
|
||||
// If validation passes or email is empty, proceed with original invite
|
||||
onInviteMember()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='rounded-[8px] shadow-xs'>
|
||||
<CardHeader className='p-4 pb-3'>
|
||||
<CardTitle className='font-medium text-sm'>Invite Team Members</CardTitle>
|
||||
<CardDescription>
|
||||
<div className='space-y-4'>
|
||||
{/* Header - clean like account page */}
|
||||
<div>
|
||||
<h4 className='font-medium text-sm'>Invite Team Members</h4>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Add new members to your team and optionally give them access to specific workspaces
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4 p-4 pt-0'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex-1'>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main invitation input - clean layout */}
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='flex-1'>
|
||||
<div>
|
||||
<Input
|
||||
placeholder='Enter email address'
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
disabled={isInviting}
|
||||
className='w-full'
|
||||
onChange={handleEmailChange}
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
className={cn('w-full', emailError && 'border-red-500 focus-visible:ring-red-500')}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setShowWorkspaceInvite(!showWorkspaceInvite)
|
||||
if (!showWorkspaceInvite) {
|
||||
onLoadUserWorkspaces()
|
||||
}
|
||||
}}
|
||||
disabled={isInviting}
|
||||
className='h-9 shrink-0 gap-1 rounded-[8px]'
|
||||
>
|
||||
{showWorkspaceInvite ? 'Hide' : 'Add'} Workspaces
|
||||
{selectedCount > 0 && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='ml-1 h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'
|
||||
>
|
||||
{selectedCount}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn('h-4 w-4 transition-transform', showWorkspaceInvite && 'rotate-180')}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={onInviteMember}
|
||||
disabled={!inviteEmail || isInviting}
|
||||
className='h-9 shrink-0 gap-2 rounded-[8px]'
|
||||
>
|
||||
{isInviting ? <ButtonSkeleton /> : <PlusCircle className='h-4 w-4' />}
|
||||
Invite
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showWorkspaceInvite && (
|
||||
<div className='space-y-3 pt-1'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h5 className='font-medium text-xs'>Workspace Access</h5>
|
||||
<Badge variant='outline' className='h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'>
|
||||
Optional
|
||||
</Badge>
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<span className='text-muted-foreground text-xs'>{selectedCount} selected</span>
|
||||
)}
|
||||
<div className='h-4 pt-1'>
|
||||
{emailError && <p className='text-red-500 text-xs'>{emailError}</p>}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs leading-relaxed'>
|
||||
Grant access to specific workspaces. You can modify permissions later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setShowWorkspaceInvite(!showWorkspaceInvite)
|
||||
if (!showWorkspaceInvite) {
|
||||
onLoadUserWorkspaces()
|
||||
}
|
||||
}}
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
className='h-9 shrink-0 rounded-[8px] text-sm'
|
||||
>
|
||||
{showWorkspaceInvite ? 'Hide' : 'Add'} Workspaces
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleInviteClick}
|
||||
disabled={!inviteEmail || isInviting || !hasAvailableSeats}
|
||||
className='h-9 shrink-0 rounded-[8px]'
|
||||
>
|
||||
{isInviting ? <ButtonSkeleton /> : null}
|
||||
{hasAvailableSeats ? 'Invite' : 'No Seats'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{userWorkspaces.length === 0 ? (
|
||||
<div className='rounded-md border border-dashed py-8 text-center'>
|
||||
<p className='text-muted-foreground text-sm'>No workspaces available</p>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
You need admin access to workspaces to invite members
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-48 space-y-2 overflow-y-auto rounded-[8px] border bg-muted/20 p-3'>
|
||||
{userWorkspaces.map((workspace) => {
|
||||
const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id)
|
||||
const selectedWorkspace = selectedWorkspaces.find(
|
||||
(w) => w.workspaceId === workspace.id
|
||||
)
|
||||
{showWorkspaceInvite && (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h5 className='font-medium text-xs'>Workspace Access</h5>
|
||||
<Badge variant='outline' className='h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'>
|
||||
Optional
|
||||
</Badge>
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<span className='text-muted-foreground text-xs'>{selectedCount} selected</span>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs leading-relaxed'>
|
||||
Grant access to specific workspaces. You can modify permissions later.
|
||||
</p>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-[8px] border bg-background p-3 transition-all',
|
||||
isSelected
|
||||
? 'border-primary/20 bg-primary/5'
|
||||
: 'hover:border-border hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{userWorkspaces.length === 0 ? (
|
||||
<div className='rounded-md border border-dashed py-8 text-center'>
|
||||
<p className='text-muted-foreground text-sm'>No workspaces available</p>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
You need admin access to workspaces to invite members
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-48 space-y-2 overflow-y-auto'>
|
||||
{userWorkspaces.map((workspace) => {
|
||||
const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id)
|
||||
const selectedWorkspace = selectedWorkspaces.find(
|
||||
(w) => w.workspaceId === workspace.id
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={workspace.id} className='flex items-center justify-between gap-2 py-1'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id={`workspace-${workspace.id}`}
|
||||
@@ -209,7 +240,7 @@ export function MemberInvitationCard({
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`workspace-${workspace.id}`}
|
||||
className='cursor-pointer font-medium text-xs leading-none'
|
||||
className='cursor-pointer font-medium text-sm'
|
||||
>
|
||||
{workspace.name}
|
||||
</Label>
|
||||
@@ -222,42 +253,43 @@ export function MemberInvitationCard({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Always reserve space for permission selector to maintain consistent layout */}
|
||||
<div className='flex h-[30px] w-32 flex-shrink-0 items-center justify-end gap-2'>
|
||||
{isSelected && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<PermissionSelector
|
||||
value={
|
||||
(['read', 'write', 'admin'].includes(
|
||||
selectedWorkspace?.permission ?? ''
|
||||
)
|
||||
? selectedWorkspace?.permission
|
||||
: 'read') as PermissionType
|
||||
}
|
||||
onChange={(permission) => onWorkspaceToggle(workspace.id, permission)}
|
||||
disabled={isInviting}
|
||||
className='h-8'
|
||||
/>
|
||||
</div>
|
||||
<PermissionSelector
|
||||
value={
|
||||
(['read', 'write', 'admin'].includes(
|
||||
selectedWorkspace?.permission ?? ''
|
||||
)
|
||||
? selectedWorkspace?.permission
|
||||
: 'read') as PermissionType
|
||||
}
|
||||
onChange={(permission) => onWorkspaceToggle(workspace.id, permission)}
|
||||
disabled={isInviting}
|
||||
className='w-auto'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inviteSuccess && (
|
||||
<Alert className='rounded-[8px] border-green-200 bg-green-50 text-green-800 dark:border-green-800/50 dark:bg-green-950/20 dark:text-green-300'>
|
||||
<CheckCircle className='h-4 w-4 text-green-600 dark:text-green-400' />
|
||||
<AlertDescription>
|
||||
Invitation sent successfully
|
||||
{selectedCount > 0 &&
|
||||
` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{inviteSuccess && (
|
||||
<Alert className='rounded-[8px] border-green-200 bg-green-50 text-green-800 dark:border-green-800/50 dark:bg-green-950/20 dark:text-green-300'>
|
||||
<CheckCircle className='h-4 w-4 text-green-600 dark:text-green-400' />
|
||||
<AlertDescription>
|
||||
Invitation sent successfully
|
||||
{selectedCount > 0 &&
|
||||
` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { MemberLimit } from './member-limit'
|
||||
@@ -1,244 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, DollarSign, User } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface MemberLimitProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
member: {
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
currentUsage: number
|
||||
usageLimit: number
|
||||
role: string
|
||||
} | null
|
||||
onSave: (userId: string, newLimit: number) => Promise<void>
|
||||
isLoading: boolean
|
||||
planType?: string
|
||||
}
|
||||
|
||||
export function MemberLimit({
|
||||
open,
|
||||
onOpenChange,
|
||||
member,
|
||||
onSave,
|
||||
isLoading,
|
||||
planType = 'team',
|
||||
}: MemberLimitProps) {
|
||||
const [limitValue, setLimitValue] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Update limit value when member changes
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
setLimitValue(member.usageLimit.toString())
|
||||
setError(null)
|
||||
}
|
||||
}, [member])
|
||||
|
||||
// Get plan minimum based on plan type
|
||||
const getPlanMinimum = (plan: string): number => {
|
||||
switch (plan) {
|
||||
case 'pro':
|
||||
return 20
|
||||
case 'team':
|
||||
return 40
|
||||
case 'enterprise':
|
||||
return 100
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
const planMinimum = getPlanMinimum(planType)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!member) return
|
||||
|
||||
const newLimit = Number.parseFloat(limitValue)
|
||||
|
||||
if (Number.isNaN(newLimit) || newLimit < 0) {
|
||||
setError('Please enter a valid positive number')
|
||||
return
|
||||
}
|
||||
|
||||
if (newLimit < planMinimum) {
|
||||
setError(
|
||||
`The limit cannot be below the ${planType} plan minimum of $${planMinimum.toFixed(2)}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (newLimit < member.currentUsage) {
|
||||
setError(
|
||||
`The new limit ($${newLimit.toFixed(2)}) cannot be lower than the member's current usage ($${member.currentUsage?.toFixed(2) || 0})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
await onSave(member.userId, newLimit)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update limit')
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => `$${amount.toFixed(2)}`
|
||||
|
||||
if (!member) return null
|
||||
|
||||
const newLimit = Number.parseFloat(limitValue) || 0
|
||||
const isIncrease = newLimit > member.usageLimit
|
||||
const isDecrease = newLimit < member.usageLimit
|
||||
const limitDifference = Math.abs(newLimit - member.usageLimit)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<User className='h-5 w-5' />
|
||||
Edit Usage Limit
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Adjust the monthly usage limit for <strong>{member.userName}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
{/* Member Info */}
|
||||
<div className='flex items-center gap-3 rounded-lg bg-muted/50 p-3'>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-medium text-primary'>
|
||||
{member.userName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='font-medium'>{member.userName}</div>
|
||||
<div className='text-muted-foreground text-sm'>{member.userEmail}</div>
|
||||
</div>
|
||||
<Badge variant={member.role === 'owner' ? 'default' : 'secondary'}>{member.role}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Current Usage Stats */}
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-sm'>Current Usage</div>
|
||||
<div className='font-semibold text-lg'>{formatCurrency(member.currentUsage)}</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-sm'>Current Limit</div>
|
||||
<div className='font-semibold text-lg'>{formatCurrency(member.usageLimit)}</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-sm'>Plan Minimum</div>
|
||||
<div className='font-semibold text-blue-600 text-lg'>
|
||||
{formatCurrency(planMinimum)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Limit Input */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='new-limit'>New Monthly Limit</Label>
|
||||
<div className='relative'>
|
||||
<DollarSign className='-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground' />
|
||||
<Input
|
||||
id='new-limit'
|
||||
type='number'
|
||||
value={limitValue}
|
||||
onChange={(e) => setLimitValue(e.target.value)}
|
||||
className='pl-9'
|
||||
min={planMinimum}
|
||||
max={10000}
|
||||
step='1'
|
||||
placeholder={planMinimum.toString()}
|
||||
autoComplete='off'
|
||||
data-form-type='other'
|
||||
name='member-usage-limit'
|
||||
/>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Minimum limit for {planType} plan: ${planMinimum}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Change Indicator */}
|
||||
{limitValue && !Number.isNaN(newLimit) && limitDifference > 0 && (
|
||||
<div
|
||||
className={`rounded-lg border p-3 ${isIncrease ? 'border-green-200 bg-green-50' : 'border-orange-200 bg-orange-50'}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 font-medium text-sm ${isIncrease ? 'text-green-700' : 'text-orange-700'}`}
|
||||
>
|
||||
{isIncrease ? '↗' : '↘'}
|
||||
{isIncrease ? 'Increasing' : 'Decreasing'} limit by{' '}
|
||||
{formatCurrency(limitDifference)}
|
||||
</div>
|
||||
<div className={`mt-1 text-xs ${isIncrease ? 'text-green-600' : 'text-orange-600'}`}>
|
||||
{isIncrease
|
||||
? 'This will give the member more usage allowance.'
|
||||
: "This will reduce the member's usage allowance."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning for below plan minimum */}
|
||||
{newLimit < planMinimum && newLimit > 0 && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTriangle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
The limit cannot be below the {planType} plan minimum of{' '}
|
||||
{formatCurrency(planMinimum)}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Warning for decreasing below current usage */}
|
||||
{newLimit < member.currentUsage && newLimit >= planMinimum && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTriangle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
The new limit is below the member's current usage. The limit must be at least{' '}
|
||||
{formatCurrency(member.currentUsage)}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTriangle className='h-4 w-4' />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !limitValue || Number.isNaN(newLimit) || newLimit < planMinimum}
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Limit'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { NoOrganizationView } from './no-organization-view'
|
||||
@@ -1,8 +1,15 @@
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { OrganizationCreationDialog } from '../organization-creation-dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface NoOrganizationViewProps {
|
||||
hasTeamPlan: boolean
|
||||
@@ -35,44 +42,47 @@ export function NoOrganizationView({
|
||||
}: NoOrganizationViewProps) {
|
||||
if (hasTeamPlan || hasEnterprisePlan) {
|
||||
return (
|
||||
<div className='space-y-4 p-6'>
|
||||
<div className='space-y-6'>
|
||||
<h3 className='font-medium text-sm'>Create Your Team Workspace</h3>
|
||||
|
||||
<div className='space-y-4 rounded-[8px] border p-4 shadow-xs'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
<div className='px-6 pt-4 pb-4'>
|
||||
<div className='flex flex-col gap-6'>
|
||||
{/* Header - matching settings page style */}
|
||||
<div>
|
||||
<h4 className='font-medium text-sm'>Create Your Team Workspace</h4>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your
|
||||
workspace to start collaborating with your team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='orgName' className='font-medium text-xs'>
|
||||
Team Name
|
||||
</label>
|
||||
<Input
|
||||
id='orgName'
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
placeholder='My Team'
|
||||
/>
|
||||
</div>
|
||||
{/* Form fields - clean layout without card */}
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Label htmlFor='orgName' className='font-medium text-sm'>
|
||||
Team Name
|
||||
</Label>
|
||||
<Input
|
||||
id='orgName'
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
placeholder='My Team'
|
||||
className='mt-1'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='orgSlug' className='font-medium text-xs'>
|
||||
Team URL
|
||||
</label>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className='rounded-l-[8px] bg-muted px-3 py-2 text-muted-foreground text-xs'>
|
||||
sim.ai/team/
|
||||
</div>
|
||||
<Input
|
||||
id='orgSlug'
|
||||
value={orgSlug}
|
||||
onChange={(e) => setOrgSlug(e.target.value)}
|
||||
className='rounded-l-none'
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor='orgSlug' className='font-medium text-sm'>
|
||||
Team URL
|
||||
</Label>
|
||||
<div className='mt-1 flex items-center'>
|
||||
<div className='rounded-l-[8px] border border-r-0 bg-muted px-3 py-2 text-muted-foreground text-sm'>
|
||||
sim.ai/team/
|
||||
</div>
|
||||
<Input
|
||||
id='orgSlug'
|
||||
value={orgSlug}
|
||||
onChange={(e) => setOrgSlug(e.target.value)}
|
||||
placeholder='my-team'
|
||||
className='rounded-l-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +93,7 @@ export function NoOrganizationView({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className='flex justify-end space-x-2'>
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
onClick={onCreateOrganization}
|
||||
disabled={!orgName || !orgSlug || isCreatingOrg}
|
||||
@@ -96,17 +106,72 @@ export function NoOrganizationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrganizationCreationDialog
|
||||
open={createOrgDialogOpen}
|
||||
onOpenChange={setCreateOrgDialogOpen}
|
||||
orgName={orgName}
|
||||
onOrgNameChange={onOrgNameChange}
|
||||
orgSlug={orgSlug}
|
||||
onOrgSlugChange={setOrgSlug}
|
||||
onCreateOrganization={onCreateOrganization}
|
||||
isCreating={isCreatingOrg}
|
||||
error={error}
|
||||
/>
|
||||
<Dialog open={createOrgDialogOpen} onOpenChange={setCreateOrgDialogOpen}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='font-medium text-sm'>Create Team Organization</DialogTitle>
|
||||
<DialogDescription className='text-muted-foreground text-xs'>
|
||||
Create a new team organization to manage members and billing.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{error && (
|
||||
<Alert variant='destructive' className='rounded-[8px]'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor='org-name' className='font-medium text-sm'>
|
||||
Organization Name
|
||||
</Label>
|
||||
<Input
|
||||
id='org-name'
|
||||
placeholder='Enter organization name'
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
disabled={isCreatingOrg}
|
||||
className='mt-1'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='org-slug' className='font-medium text-sm'>
|
||||
Organization Slug
|
||||
</Label>
|
||||
<Input
|
||||
id='org-slug'
|
||||
placeholder='organization-slug'
|
||||
value={orgSlug}
|
||||
onChange={(e) => setOrgSlug(e.target.value)}
|
||||
disabled={isCreatingOrg}
|
||||
className='mt-1'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-2 pt-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setCreateOrgDialogOpen(false)}
|
||||
disabled={isCreatingOrg}
|
||||
className='h-9 rounded-[8px]'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCreateOrganization}
|
||||
disabled={isCreatingOrg || !orgName.trim()}
|
||||
className='h-9 rounded-[8px]'
|
||||
>
|
||||
{isCreatingOrg && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
|
||||
Create Organization
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { OrganizationCreationDialog } from './organization-creation-dialog'
|
||||
@@ -1,100 +0,0 @@
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface OrganizationCreationDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
orgName: string
|
||||
onOrgNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
orgSlug: string
|
||||
onOrgSlugChange: (slug: string) => void
|
||||
onCreateOrganization: () => Promise<void>
|
||||
isCreating: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function OrganizationCreationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
orgName,
|
||||
onOrgNameChange,
|
||||
orgSlug,
|
||||
onOrgSlugChange,
|
||||
onCreateOrganization,
|
||||
isCreating,
|
||||
error,
|
||||
}: OrganizationCreationDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Team Workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a workspace for your team to collaborate on projects.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='orgName' className='font-medium text-xs'>
|
||||
Team Name
|
||||
</label>
|
||||
<Input id='orgName' value={orgName} onChange={onOrgNameChange} placeholder='My Team' />
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='orgSlug' className='font-medium text-xs'>
|
||||
Team URL
|
||||
</label>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className='rounded-l-[8px] bg-muted px-3 py-2 text-muted-foreground text-xs'>
|
||||
sim.ai/team/
|
||||
</div>
|
||||
<Input
|
||||
value={orgSlug}
|
||||
onChange={(e) => onOrgSlugChange(e.target.value)}
|
||||
className='rounded-l-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant='destructive' className='rounded-[8px]'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isCreating}
|
||||
className='h-9 rounded-[8px]'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCreateOrganization}
|
||||
disabled={!orgName || !orgSlug || isCreating}
|
||||
className='h-9 rounded-[8px]'
|
||||
>
|
||||
{isCreating && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
|
||||
Create Team Workspace
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { OrganizationSettingsTab } from './organization-settings-tab'
|
||||
@@ -1,136 +0,0 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { Organization, OrganizationFormData } from '@/stores/organization'
|
||||
|
||||
interface OrganizationSettingsTabProps {
|
||||
organization: Organization
|
||||
isAdminOrOwner: boolean
|
||||
userRole: string
|
||||
orgFormData: OrganizationFormData
|
||||
onOrgInputChange: (field: string, value: string) => void
|
||||
onSaveOrgSettings: () => Promise<void>
|
||||
isSavingOrgSettings: boolean
|
||||
orgSettingsError: string | null
|
||||
orgSettingsSuccess: string | null
|
||||
}
|
||||
|
||||
export function OrganizationSettingsTab({
|
||||
organization,
|
||||
isAdminOrOwner,
|
||||
userRole,
|
||||
orgFormData,
|
||||
onOrgInputChange,
|
||||
onSaveOrgSettings,
|
||||
isSavingOrgSettings,
|
||||
orgSettingsError,
|
||||
orgSettingsSuccess,
|
||||
}: OrganizationSettingsTabProps) {
|
||||
return (
|
||||
<div className='mt-4 space-y-4'>
|
||||
{orgSettingsError && (
|
||||
<Alert variant='destructive' className='rounded-[8px]'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{orgSettingsError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{orgSettingsSuccess && (
|
||||
<Alert className='rounded-[8px]'>
|
||||
<AlertTitle>Success</AlertTitle>
|
||||
<AlertDescription>{orgSettingsSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isAdminOrOwner && (
|
||||
<Alert className='rounded-[8px]'>
|
||||
<AlertTitle>Read Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
You need owner or admin permissions to modify team settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card className='rounded-[8px] shadow-xs'>
|
||||
<CardHeader className='p-4 pb-3'>
|
||||
<CardTitle className='font-medium text-sm'>Basic Information</CardTitle>
|
||||
<CardDescription>Update your team's basic information and branding</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4 p-4 pt-0'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='team-name'>Team Name</Label>
|
||||
<Input
|
||||
id='team-name'
|
||||
value={orgFormData.name}
|
||||
onChange={(e) => onOrgInputChange('name', e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isAdminOrOwner && !isSavingOrgSettings) {
|
||||
onSaveOrgSettings()
|
||||
}
|
||||
}}
|
||||
placeholder='Enter team name'
|
||||
disabled={!isAdminOrOwner || isSavingOrgSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='team-slug'>Team Slug</Label>
|
||||
<Input
|
||||
id='team-slug'
|
||||
value={orgFormData.slug}
|
||||
onChange={(e) => onOrgInputChange('slug', e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isAdminOrOwner && !isSavingOrgSettings) {
|
||||
onSaveOrgSettings()
|
||||
}
|
||||
}}
|
||||
placeholder='team-slug'
|
||||
disabled={!isAdminOrOwner || isSavingOrgSettings}
|
||||
/>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Used in URLs and API references. Can only contain lowercase letters, numbers, hyphens,
|
||||
and underscores.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='team-logo'>Logo URL (Optional)</Label>
|
||||
<Input
|
||||
id='team-logo'
|
||||
value={orgFormData.logo}
|
||||
onChange={(e) => onOrgInputChange('logo', e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isAdminOrOwner && !isSavingOrgSettings) {
|
||||
onSaveOrgSettings()
|
||||
}
|
||||
}}
|
||||
placeholder='https://example.com/logo.png'
|
||||
disabled={!isAdminOrOwner || isSavingOrgSettings}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='rounded-[8px] shadow-xs'>
|
||||
<CardHeader className='p-4 pb-3'>
|
||||
<CardTitle className='font-medium text-sm'>Team Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2 p-4 pt-0 text-xs'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Team ID:</span>
|
||||
<span className='font-mono'>{organization.id}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Created:</span>
|
||||
<span>{new Date(organization.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Your Role:</span>
|
||||
<span className='font-medium capitalize'>{userRole}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { PendingInvitationsList } from './pending-invitations-list'
|
||||
@@ -1,53 +0,0 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Invitation, Organization } from '@/stores/organization'
|
||||
|
||||
interface PendingInvitationsListProps {
|
||||
organization: Organization
|
||||
onCancelInvitation: (invitationId: string) => void
|
||||
}
|
||||
|
||||
export function PendingInvitationsList({
|
||||
organization,
|
||||
onCancelInvitation,
|
||||
}: PendingInvitationsListProps) {
|
||||
const pendingInvitations = organization.invitations?.filter(
|
||||
(invitation) => invitation.status === 'pending'
|
||||
)
|
||||
|
||||
if (!pendingInvitations || pendingInvitations.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-[8px] border shadow-xs'>
|
||||
<h4 className='border-b p-4 pb-3 font-medium text-sm'>Pending Invitations</h4>
|
||||
<div className='divide-y'>
|
||||
{pendingInvitations.map((invitation: Invitation) => (
|
||||
<div key={invitation.id} className='flex items-center justify-between p-4'>
|
||||
<div className='flex-1'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground text-sm'>
|
||||
{invitation.email.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='font-medium text-sm'>{invitation.email}</div>
|
||||
<div className='text-muted-foreground text-xs'>Invitation pending</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onCancelInvitation(invitation.id)}
|
||||
className='h-8 w-8 rounded-[8px] p-0'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { RemoveMemberDialog } from './remove-member-dialog'
|
||||
@@ -1 +0,0 @@
|
||||
export { TeamMembersList } from './team-members-list'
|
||||
@@ -1,68 +0,0 @@
|
||||
import { UserX } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Member, Organization } from '@/stores/organization'
|
||||
|
||||
interface TeamMembersListProps {
|
||||
organization: Organization
|
||||
currentUserEmail: string
|
||||
isAdminOrOwner: boolean
|
||||
onRemoveMember: (member: Member) => void
|
||||
}
|
||||
|
||||
export function TeamMembersList({
|
||||
organization,
|
||||
currentUserEmail,
|
||||
isAdminOrOwner,
|
||||
onRemoveMember,
|
||||
}: TeamMembersListProps) {
|
||||
if (!organization.members || organization.members.length === 0) {
|
||||
return (
|
||||
<div className='rounded-[8px] border shadow-xs'>
|
||||
<h4 className='border-b p-4 pb-3 font-medium text-sm'>Team Members</h4>
|
||||
<div className='p-4 text-muted-foreground text-sm'>
|
||||
No members in this organization yet.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-[8px] border shadow-xs'>
|
||||
<h4 className='border-b p-4 pb-3 font-medium text-sm'>Team Members</h4>
|
||||
<div className='divide-y'>
|
||||
{organization.members.map((member: Member) => (
|
||||
<div key={member.id} className='flex items-center justify-between p-4'>
|
||||
<div className='flex-1'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 font-medium text-primary text-sm'>
|
||||
{(member.user?.name || member.user?.email || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='font-medium text-sm'>{member.user?.name || 'Unknown'}</div>
|
||||
<div className='text-muted-foreground text-xs'>{member.user?.email}</div>
|
||||
</div>
|
||||
<div className='h-[1.125rem] rounded-[6px] bg-primary/10 px-2 py-0 font-medium text-primary text-xs'>
|
||||
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Only show remove button for non-owners and if current user is admin/owner */}
|
||||
{isAdminOrOwner &&
|
||||
member.role !== 'owner' &&
|
||||
member.user?.email !== currentUserEmail && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onRemoveMember(member)}
|
||||
className='h-8 w-8 rounded-[8px] p-0'
|
||||
>
|
||||
<UserX className='h-4 w-4' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { UserX, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Invitation, Member, Organization } from '@/stores/organization'
|
||||
|
||||
interface ConsolidatedTeamMembersProps {
|
||||
organization: Organization
|
||||
currentUserEmail: string
|
||||
isAdminOrOwner: boolean
|
||||
onRemoveMember: (member: Member) => void
|
||||
onCancelInvitation: (invitationId: string) => void
|
||||
}
|
||||
|
||||
interface TeamMemberItem {
|
||||
type: 'member' | 'invitation'
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
usage?: string
|
||||
lastActive?: string
|
||||
member?: Member
|
||||
invitation?: Invitation
|
||||
}
|
||||
|
||||
export function TeamMembers({
|
||||
organization,
|
||||
currentUserEmail,
|
||||
isAdminOrOwner,
|
||||
onRemoveMember,
|
||||
onCancelInvitation,
|
||||
}: ConsolidatedTeamMembersProps) {
|
||||
// Combine members and pending invitations into a single list
|
||||
const teamItems: TeamMemberItem[] = []
|
||||
|
||||
// Add existing members
|
||||
if (organization.members) {
|
||||
organization.members.forEach((member: Member) => {
|
||||
teamItems.push({
|
||||
type: 'member',
|
||||
id: member.id,
|
||||
name: member.user?.name || 'Unknown',
|
||||
email: member.user?.email || '',
|
||||
role: member.role,
|
||||
usage: '$0.00', // TODO: Get real usage data
|
||||
lastActive: '8/26/2025', // TODO: Get real last active date
|
||||
member,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Add pending invitations
|
||||
const pendingInvitations = organization.invitations?.filter(
|
||||
(invitation) => invitation.status === 'pending'
|
||||
)
|
||||
if (pendingInvitations) {
|
||||
pendingInvitations.forEach((invitation: Invitation) => {
|
||||
teamItems.push({
|
||||
type: 'invitation',
|
||||
id: invitation.id,
|
||||
name: invitation.email.split('@')[0], // Use email prefix as name
|
||||
email: invitation.email,
|
||||
role: 'pending',
|
||||
usage: '-',
|
||||
lastActive: '-',
|
||||
invitation,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (teamItems.length === 0) {
|
||||
return <div className='text-center text-muted-foreground text-sm'>No team members yet.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* Header - simple like account page */}
|
||||
<div>
|
||||
<h4 className='font-medium text-sm'>Team Members</h4>
|
||||
</div>
|
||||
|
||||
{/* Members list - clean like account page */}
|
||||
<div className='space-y-4'>
|
||||
{teamItems.map((item) => (
|
||||
<div key={item.id} className='flex items-center justify-between'>
|
||||
{/* Member info */}
|
||||
<div className='flex flex-1 items-center gap-3'>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full font-medium text-sm ${
|
||||
item.type === 'member'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{item.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
{/* Name and email */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='truncate font-medium text-sm'>{item.name}</span>
|
||||
{item.type === 'member' && (
|
||||
<span
|
||||
className={`inline-flex h-[1.125rem] items-center rounded-[6px] px-2 py-0 font-medium text-xs ${
|
||||
item.role === 'owner'
|
||||
? 'gradient-text border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
|
||||
: 'bg-primary/10 text-primary'
|
||||
} `}
|
||||
>
|
||||
{item.role.charAt(0).toUpperCase() + item.role.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
{item.type === 'invitation' && (
|
||||
<span className='inline-flex h-[1.125rem] items-center rounded-[6px] bg-muted px-2 py-0 font-medium text-muted-foreground text-xs'>
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='truncate text-muted-foreground text-xs'>{item.email}</div>
|
||||
</div>
|
||||
|
||||
{/* Usage and stats - matching subscription layout */}
|
||||
<div className='hidden items-center gap-4 text-xs tabular-nums sm:flex'>
|
||||
<div className='text-center'>
|
||||
<div className='text-muted-foreground'>Usage</div>
|
||||
<div className='font-medium'>{item.usage}</div>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<div className='text-muted-foreground'>Active</div>
|
||||
<div className='font-medium'>{item.lastActive}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{isAdminOrOwner && (
|
||||
<div className='ml-4'>
|
||||
{item.type === 'member' &&
|
||||
item.member?.role !== 'owner' &&
|
||||
item.email !== currentUserEmail && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onRemoveMember(item.member!)}
|
||||
className='h-8 w-8 rounded-[8px] p-0'
|
||||
>
|
||||
<UserX className='h-4 w-4' />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.type === 'invitation' && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onCancelInvitation(item.invitation!.id)}
|
||||
className='h-8 w-8 rounded-[8px] p-0'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { TeamSeatsOverview } from './team-seats-overview'
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Building2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
type Subscription = {
|
||||
id: string
|
||||
@@ -29,9 +30,25 @@ interface TeamSeatsOverviewProps {
|
||||
|
||||
function TeamSeatsSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Skeleton className='h-4 w-4' />
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-5 w-16' />
|
||||
<Skeleton className='h-4 w-20' />
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-xs'>
|
||||
<Skeleton className='h-4 w-8' />
|
||||
<span className='text-muted-foreground'>/</span>
|
||||
<Skeleton className='h-4 w-8' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-2 w-full rounded' />
|
||||
<div className='flex gap-2 pt-1'>
|
||||
<Skeleton className='h-8 flex-1 rounded-[8px]' />
|
||||
<Skeleton className='h-8 flex-1 rounded-[8px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -46,123 +63,86 @@ export function TeamSeatsOverview({
|
||||
onAddSeatDialog,
|
||||
}: TeamSeatsOverviewProps) {
|
||||
if (isLoadingSubscription) {
|
||||
return (
|
||||
<Card className='rounded-[8px] shadow-xs'>
|
||||
<CardHeader className='p-4 pb-3'>
|
||||
<CardTitle className='font-medium text-sm'>Team Seats Overview</CardTitle>
|
||||
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='p-4 pt-0'>
|
||||
<TeamSeatsSkeleton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <TeamSeatsSkeleton />
|
||||
}
|
||||
|
||||
if (!subscriptionData) {
|
||||
return (
|
||||
<Card className='rounded-[8px] shadow-xs'>
|
||||
<CardHeader className='p-4 pb-3'>
|
||||
<CardTitle className='font-medium text-sm'>Team Seats Overview</CardTitle>
|
||||
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='p-4 pt-0'>
|
||||
<div className='space-y-4 p-6 text-center'>
|
||||
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-amber-100'>
|
||||
<Building2 className='h-6 w-6 text-amber-600' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-medium text-sm'>No Team Subscription Found</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Your subscription may need to be transferred to this organization.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onConfirmTeamUpgrade(2) // Start with 2 seats as default
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className='h-9 rounded-[8px]'
|
||||
>
|
||||
Set Up Team Subscription
|
||||
</Button>
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<div className='space-y-4 text-center'>
|
||||
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-amber-100'>
|
||||
<Building2 className='h-6 w-6 text-amber-600' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-medium text-sm'>No Team Subscription Found</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Your subscription may need to be transferred to this organization.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onConfirmTeamUpgrade(2) // Start with 2 seats as default
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className='h-9 rounded-[8px]'
|
||||
>
|
||||
Set Up Team Subscription
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='rounded-[8px] shadow-xs'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
|
||||
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='p-4 pt-0'>
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-3 gap-4 text-center'>
|
||||
<div className='space-y-1'>
|
||||
<p className='font-bold text-xl'>{subscriptionData.seats || 0}</p>
|
||||
<p className='text-muted-foreground text-xs'>Licensed Seats</p>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<p className='font-bold text-xl'>{usedSeats}</p>
|
||||
<p className='text-muted-foreground text-xs'>Used Seats</p>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<p className='font-bold text-xl'>{(subscriptionData.seats || 0) - usedSeats}</p>
|
||||
<p className='text-muted-foreground text-xs'>Available</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span>Seat Usage</span>
|
||||
<span>
|
||||
{usedSeats} of {subscriptionData.seats || 0} seats
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={(usedSeats / (subscriptionData.seats || 1)) * 100} className='h-2' />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-t pt-2 text-sm'>
|
||||
<span>Seat Cost:</span>
|
||||
<span className='font-semibold'>
|
||||
${((subscriptionData.seats || 0) * 40).toFixed(2)}
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<div className='space-y-2'>
|
||||
{/* Seats info and usage - matching team usage layout */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-sm'>Seats</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
(${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
|
||||
</span>
|
||||
</div>
|
||||
<div className='mt-2 text-muted-foreground text-xs'>
|
||||
Individual usage limits may vary. See Subscription tab for team totals.
|
||||
<div className='flex items-center gap-1 text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground'>{usedSeats} used</span>
|
||||
<span className='text-muted-foreground'>/</span>
|
||||
<span className='text-muted-foreground'>{subscriptionData.seats || 0} total</span>
|
||||
</div>
|
||||
|
||||
{checkEnterprisePlan(subscriptionData) ? (
|
||||
<div className='rounded-[8px] bg-purple-50 p-4 text-center'>
|
||||
<p className='font-medium text-purple-700 text-sm'>Enterprise Plan</p>
|
||||
<p className='mt-1 text-purple-600 text-xs'>Contact support to modify seats</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onReduceSeats}
|
||||
disabled={(subscriptionData.seats || 0) <= 1 || isLoading}
|
||||
className='h-9 flex-1 rounded-[8px]'
|
||||
>
|
||||
Remove Seat
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={onAddSeatDialog}
|
||||
disabled={isLoading}
|
||||
className='h-9 flex-1 rounded-[8px]'
|
||||
>
|
||||
Add Seat
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Progress Bar - matching team usage component */}
|
||||
<Progress value={(usedSeats / (subscriptionData.seats || 1)) * 100} className='h-2' />
|
||||
|
||||
{/* Action buttons - below the usage display */}
|
||||
{checkEnterprisePlan(subscriptionData) ? (
|
||||
<div className='text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Contact enterprise for support usage limit changes
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex gap-2 pt-1'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onReduceSeats}
|
||||
disabled={(subscriptionData.seats || 0) <= 1 || isLoading}
|
||||
className='h-8 flex-1 rounded-[8px]'
|
||||
>
|
||||
Remove Seat
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={onAddSeatDialog}
|
||||
disabled={isLoading}
|
||||
className='h-8 flex-1 rounded-[8px]'
|
||||
>
|
||||
Add Seat
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { TeamSeats } from './team-seats'
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
interface TeamSeatsProps {
|
||||
@@ -29,6 +31,7 @@ interface TeamSeatsProps {
|
||||
onConfirm: (seats: number) => Promise<void>
|
||||
confirmButtonText: string
|
||||
showCostBreakdown?: boolean
|
||||
isCancelledAtPeriodEnd?: boolean
|
||||
}
|
||||
|
||||
export function TeamSeats({
|
||||
@@ -42,6 +45,7 @@ export function TeamSeats({
|
||||
onConfirm,
|
||||
confirmButtonText,
|
||||
showCostBreakdown = false,
|
||||
isCancelledAtPeriodEnd = false,
|
||||
}: TeamSeatsProps) {
|
||||
const [selectedSeats, setSelectedSeats] = useState(initialSeats)
|
||||
|
||||
@@ -51,7 +55,7 @@ export function TeamSeats({
|
||||
}
|
||||
}, [open, initialSeats])
|
||||
|
||||
const costPerSeat = env.TEAM_TIER_COST_LIMIT ?? 40
|
||||
const costPerSeat = env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT
|
||||
const totalMonthlyCost = selectedSeats * costPerSeat
|
||||
const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0
|
||||
|
||||
@@ -114,19 +118,39 @@ export function TeamSeats({
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading || (showCostBreakdown && selectedSeats === currentSeats)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-current border-b-transparent' />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{confirmButtonText}</span>
|
||||
)}
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
isLoading ||
|
||||
(showCostBreakdown && selectedSeats === currentSeats) ||
|
||||
isCancelledAtPeriodEnd
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-current border-b-transparent' />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{confirmButtonText}</span>
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isCancelledAtPeriodEnd && (
|
||||
<TooltipContent>
|
||||
<p>
|
||||
To update seats, go to Subscription {'>'} Manage {'>'} Keep Subscription to
|
||||
reactivate
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { TeamUsage } from './team-usage'
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertCircle, Settings2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useActiveOrganization } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { UsageHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header'
|
||||
import {
|
||||
UsageLimit,
|
||||
type UsageLimitRef,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import type { MemberUsageData } from '@/stores/organization/types'
|
||||
import { MemberLimit } from '../member-limit'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
|
||||
const logger = createLogger('TeamUsage')
|
||||
|
||||
@@ -19,14 +20,11 @@ interface TeamUsageProps {
|
||||
|
||||
export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
|
||||
const { data: activeOrg } = useActiveOrganization()
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [selectedMember, setSelectedMember] = useState<MemberUsageData | null>(null)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
const { getSubscriptionStatus } = useSubscriptionStore()
|
||||
|
||||
const {
|
||||
organizationBillingData: billingData,
|
||||
loadOrganizationBillingData,
|
||||
updateMemberUsageLimit,
|
||||
isLoadingOrgBilling,
|
||||
error,
|
||||
} = useOrganizationStore()
|
||||
@@ -37,143 +35,35 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
|
||||
}
|
||||
}, [activeOrg?.id, loadOrganizationBillingData])
|
||||
|
||||
const handleEditLimit = (member: MemberUsageData) => {
|
||||
setSelectedMember(member)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveLimit = async (userId: string, newLimit: number): Promise<void> => {
|
||||
if (!activeOrg?.id) {
|
||||
throw new Error('No active organization found')
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUpdating(true)
|
||||
const result = await updateMemberUsageLimit(userId, activeOrg.id, newLimit)
|
||||
|
||||
if (!result.success) {
|
||||
logger.error('Failed to update usage limit', { error: result.error, userId, newLimit })
|
||||
throw new Error(result.error || 'Failed to update usage limit')
|
||||
const handleLimitUpdated = useCallback(
|
||||
async (newLimit: number) => {
|
||||
// Reload the organization billing data to reflect the new limit
|
||||
if (activeOrg?.id) {
|
||||
await loadOrganizationBillingData(activeOrg.id, true)
|
||||
}
|
||||
},
|
||||
[activeOrg?.id, loadOrganizationBillingData]
|
||||
)
|
||||
|
||||
logger.info('Successfully updated member usage limit', {
|
||||
userId,
|
||||
newLimit,
|
||||
organizationId: activeOrg.id,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to update usage limit'
|
||||
logger.error('Failed to update usage limit', {
|
||||
error,
|
||||
userId,
|
||||
newLimit,
|
||||
organizationId: activeOrg.id,
|
||||
})
|
||||
throw new Error(errorMessage)
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseEditDialog = () => {
|
||||
setEditDialogOpen(false)
|
||||
setSelectedMember(null)
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => `$${amount.toFixed(2)}`
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never'
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
const usageLimitRef = useRef<UsageLimitRef | null>(null)
|
||||
|
||||
if (isLoadingOrgBilling) {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* Table Skeleton */}
|
||||
<Card className='border-0 shadow-sm'>
|
||||
<CardContent className='p-0'>
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
{/* Table Header Skeleton */}
|
||||
<div className='bg-muted/30 px-6 py-4'>
|
||||
<div className='grid grid-cols-12 gap-4'>
|
||||
<div className='col-span-4'>
|
||||
<Skeleton className='h-3 w-16' />
|
||||
</div>
|
||||
<div className='col-span-2 flex justify-center'>
|
||||
<Skeleton className='h-3 w-8' />
|
||||
</div>
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<Skeleton className='ml-auto h-3 w-12' />
|
||||
</div>
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<Skeleton className='ml-auto h-3 w-12' />
|
||||
</div>
|
||||
<div className='col-span-1 hidden text-center lg:block'>
|
||||
<Skeleton className='mx-auto h-3 w-12' />
|
||||
</div>
|
||||
<div className='col-span-1' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body Skeleton */}
|
||||
<div className='divide-y divide-border'>
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div key={index} className='px-6 py-4'>
|
||||
<div className='grid grid-cols-12 items-center gap-4'>
|
||||
{/* Member Info Skeleton */}
|
||||
<div className='col-span-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Skeleton className='h-8 w-8 rounded-full' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<Skeleton className='mt-1 h-3 w-32' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile-only usage info skeleton */}
|
||||
<div className='mt-3 grid grid-cols-2 gap-4 sm:hidden'>
|
||||
<div>
|
||||
<Skeleton className='h-3 w-10' />
|
||||
<Skeleton className='mt-1 h-4 w-16' />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className='h-3 w-8' />
|
||||
<Skeleton className='mt-1 h-4 w-16' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Skeleton */}
|
||||
<div className='col-span-2 flex justify-center'>
|
||||
<Skeleton className='h-4 w-12' />
|
||||
</div>
|
||||
|
||||
{/* Usage - Desktop Skeleton */}
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<Skeleton className='ml-auto h-4 w-16' />
|
||||
</div>
|
||||
|
||||
{/* Limit - Desktop Skeleton */}
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<Skeleton className='ml-auto h-4 w-16' />
|
||||
</div>
|
||||
|
||||
{/* Last Active - Desktop Skeleton */}
|
||||
<div className='col-span-1 hidden text-center lg:block'>
|
||||
<Skeleton className='mx-auto h-3 w-16' />
|
||||
</div>
|
||||
|
||||
{/* Actions Skeleton */}
|
||||
<div className='col-span-1 text-center'>
|
||||
<Skeleton className='mx-auto h-8 w-8' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-5 w-16' />
|
||||
<Skeleton className='h-4 w-20' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className='flex items-center gap-1 text-xs'>
|
||||
<Skeleton className='h-4 w-8' />
|
||||
<span className='text-muted-foreground'>/</span>
|
||||
<Skeleton className='h-4 w-8' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-2 w-full rounded' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -189,160 +79,79 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
|
||||
}
|
||||
|
||||
if (!billingData) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>No Data</AlertTitle>
|
||||
<AlertDescription>No billing data available for this organization.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const membersOverLimit = billingData.members?.filter((m) => m.isOverLimit).length || 0
|
||||
const membersNearLimit =
|
||||
billingData.members?.filter((m) => !m.isOverLimit && m.percentUsed >= 80).length || 0
|
||||
const currentUsage = billingData.totalCurrentUsage || 0
|
||||
const currentCap = billingData.totalUsageLimit || billingData.minimumBillingAmount || 0
|
||||
const minimumBilling = billingData.minimumBillingAmount || 0
|
||||
const seatsCount = billingData.seatsCount || 1
|
||||
const percentUsed =
|
||||
currentCap > 0 ? Math.round(Math.min((currentUsage / currentCap) * 100, 100)) : 0
|
||||
const status: 'ok' | 'warning' | 'exceeded' =
|
||||
percentUsed >= 100 ? 'exceeded' : percentUsed >= 80 ? 'warning' : 'ok'
|
||||
|
||||
const subscription = getSubscriptionStatus()
|
||||
const title = subscription.isEnterprise
|
||||
? 'Enterprise'
|
||||
: subscription.isTeam
|
||||
? 'Team'
|
||||
: (subscription.plan || 'Free').charAt(0).toUpperCase() +
|
||||
(subscription.plan || 'Free').slice(1)
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
{/* Alerts */}
|
||||
{membersOverLimit > 0 && (
|
||||
<div className='rounded-lg border border-orange-200 bg-orange-50 p-6'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='flex h-9 w-9 items-center justify-center rounded-full bg-orange-100'>
|
||||
<AlertCircle className='h-5 w-5 text-orange-600' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<h4 className='font-medium text-orange-800 text-sm'>Usage Limits Exceeded</h4>
|
||||
<p className='mt-2 text-orange-700 text-sm'>
|
||||
{membersOverLimit} team {membersOverLimit === 1 ? 'member has' : 'members have'}{' '}
|
||||
exceeded their usage limits. Consider increasing their limits below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member Usage Table */}
|
||||
<Card className='border-0 shadow-sm'>
|
||||
<CardContent className='p-0'>
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
{/* Table Header */}
|
||||
<div className='bg-muted/30 px-6 py-4'>
|
||||
<div className='grid grid-cols-12 gap-4 font-medium text-muted-foreground text-xs'>
|
||||
<div className='col-span-4'>Member</div>
|
||||
<div className='col-span-2 text-center'>Role</div>
|
||||
<div className='col-span-2 hidden text-right sm:block'>Usage</div>
|
||||
<div className='col-span-2 hidden text-right sm:block'>Limit</div>
|
||||
<div className='col-span-1 hidden text-center lg:block'>Active</div>
|
||||
<div className='col-span-1 text-center' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
<div className='divide-y divide-border'>
|
||||
{billingData.members && billingData.members.length > 0 ? (
|
||||
billingData.members.map((member) => (
|
||||
<div
|
||||
key={member.userId}
|
||||
className='group px-6 py-4 transition-colors hover:bg-muted/30'
|
||||
>
|
||||
<div className='grid grid-cols-12 items-center gap-4'>
|
||||
{/* Member Info */}
|
||||
<div className='col-span-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 font-semibold text-primary text-xs'>
|
||||
{member.userName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-sm'>{member.userName}</div>
|
||||
<div className='mt-0.5 truncate text-muted-foreground text-xs'>
|
||||
{member.userEmail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile-only usage info */}
|
||||
<div className='mt-3 grid grid-cols-2 gap-4 sm:hidden'>
|
||||
<div>
|
||||
<div className='text-muted-foreground text-xs'>Usage</div>
|
||||
<div className='font-medium text-sm'>
|
||||
{formatCurrency(member.currentUsage)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-muted-foreground text-xs'>Limit</div>
|
||||
<div className='font-medium text-sm'>
|
||||
{formatCurrency(member.usageLimit)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className='col-span-2 flex justify-center'>
|
||||
<Badge variant='secondary' className='text-xs'>
|
||||
{member.role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Usage - Desktop */}
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<div className='font-medium text-sm'>
|
||||
{formatCurrency(member.currentUsage)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Limit - Desktop */}
|
||||
<div className='col-span-2 hidden text-right sm:block'>
|
||||
<div className='font-medium text-sm'>
|
||||
{formatCurrency(member.usageLimit)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Active - Desktop */}
|
||||
<div className='col-span-1 hidden text-center lg:block'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{formatDate(member.lastActive)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className='col-span-1 text-center'>
|
||||
{hasAdminAccess && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
onClick={() => handleEditLimit(member)}
|
||||
disabled={isUpdating}
|
||||
className='h-8 w-8 p-0 opacity-0 transition-opacity group-hover:opacity-100 sm:opacity-100'
|
||||
title='Edit usage limit'
|
||||
>
|
||||
<Settings2 className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='px-6 py-8 text-center'>
|
||||
<div className='text-muted-foreground text-sm'>No team members found.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Member Limit Dialog */}
|
||||
<MemberLimit
|
||||
open={editDialogOpen}
|
||||
onOpenChange={handleCloseEditDialog}
|
||||
member={selectedMember}
|
||||
onSave={handleSaveLimit}
|
||||
isLoading={isUpdating}
|
||||
planType='team'
|
||||
/>
|
||||
</div>
|
||||
<UsageHeader
|
||||
title={title}
|
||||
gradientTitle={!subscription.isFree}
|
||||
showBadge={!!(hasAdminAccess && activeOrg?.id && !subscription.isEnterprise)}
|
||||
badgeText={subscription.isEnterprise ? undefined : 'Increase Limit'}
|
||||
onBadgeClick={() => {
|
||||
if (!subscription.isEnterprise) usageLimitRef.current?.startEdit()
|
||||
}}
|
||||
seatsText={`${seatsCount} seats`}
|
||||
current={currentUsage}
|
||||
limit={currentCap}
|
||||
isBlocked={Boolean(billingData?.billingBlocked)}
|
||||
status={status}
|
||||
percentUsed={percentUsed}
|
||||
onResolvePayment={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/billing/portal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
context: 'organization',
|
||||
organizationId: activeOrg?.id,
|
||||
returnUrl: `${window.location.origin}/workspace?billing=updated`,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok || !data?.url)
|
||||
throw new Error(data?.error || 'Failed to start billing portal')
|
||||
window.location.href = data.url
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to open billing portal')
|
||||
}
|
||||
}}
|
||||
rightContent={
|
||||
hasAdminAccess && activeOrg?.id && !subscription.isEnterprise ? (
|
||||
<UsageLimit
|
||||
ref={usageLimitRef}
|
||||
currentLimit={currentCap}
|
||||
currentUsage={currentUsage}
|
||||
canEdit={hasAdminAccess && !subscription.isEnterprise}
|
||||
minimumLimit={minimumBilling}
|
||||
context='organization'
|
||||
organizationId={activeOrg.id}
|
||||
onLimitUpdated={handleLimitUpdated}
|
||||
/>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-xs tabular-nums'>
|
||||
${currentCap.toFixed(0)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
progressValue={percentUsed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Skeleton,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui'
|
||||
import { Alert, AlertDescription, AlertTitle, Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateSlug, useOrganizationStore } from '@/stores/organization'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import {
|
||||
MemberInvitationCard,
|
||||
NoOrganizationView,
|
||||
OrganizationSettingsTab,
|
||||
PendingInvitationsList,
|
||||
RemoveMemberDialog,
|
||||
TeamMembersList,
|
||||
TeamMembers,
|
||||
TeamSeats,
|
||||
TeamSeatsOverview,
|
||||
TeamUsage,
|
||||
} from './components'
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components'
|
||||
import { generateSlug, useOrganizationStore } from '@/stores/organization'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
|
||||
const logger = createLogger('TeamManagement')
|
||||
|
||||
@@ -37,18 +27,14 @@ export function TeamManagement() {
|
||||
activeOrganization,
|
||||
subscriptionData,
|
||||
userWorkspaces,
|
||||
orgFormData,
|
||||
hasTeamPlan,
|
||||
hasEnterprisePlan,
|
||||
isLoading,
|
||||
isLoadingSubscription,
|
||||
isCreatingOrg,
|
||||
isInviting,
|
||||
isSavingOrgSettings,
|
||||
error,
|
||||
orgSettingsError,
|
||||
inviteSuccess,
|
||||
orgSettingsSuccess,
|
||||
loadData,
|
||||
createOrganization,
|
||||
setActiveOrganization,
|
||||
@@ -57,12 +43,10 @@ export function TeamManagement() {
|
||||
cancelInvitation,
|
||||
addSeats,
|
||||
reduceSeats,
|
||||
updateOrganizationSettings,
|
||||
loadUserWorkspaces,
|
||||
getUserRole,
|
||||
isAdminOrOwner,
|
||||
getUsedSeats,
|
||||
setOrgFormData,
|
||||
} = useOrganizationStore()
|
||||
|
||||
const { getSubscriptionStatus } = useSubscriptionStore()
|
||||
@@ -81,7 +65,6 @@ export function TeamManagement() {
|
||||
}>({ open: false, memberId: '', memberName: '', shouldReduceSeats: false })
|
||||
const [orgName, setOrgName] = useState('')
|
||||
const [orgSlug, setOrgSlug] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('members')
|
||||
const [isAddSeatDialogOpen, setIsAddSeatDialogOpen] = useState(false)
|
||||
const [newSeatCount, setNewSeatCount] = useState(1)
|
||||
const [isUpdatingSeats, setIsUpdatingSeats] = useState(false)
|
||||
@@ -99,7 +82,6 @@ export function TeamManagement() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Set default organization name for team/enterprise users
|
||||
useEffect(() => {
|
||||
if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) {
|
||||
const defaultName = `${session.user.name}'s Team`
|
||||
@@ -108,7 +90,6 @@ export function TeamManagement() {
|
||||
}
|
||||
}, [hasTeamPlan, hasEnterprisePlan, session?.user?.name, orgName])
|
||||
|
||||
// Load workspaces for admin users
|
||||
const activeOrgId = activeOrganization?.id
|
||||
useEffect(() => {
|
||||
if (session?.user?.id && activeOrgId && adminOrOwner) {
|
||||
@@ -124,11 +105,39 @@ export function TeamManagement() {
|
||||
|
||||
const handleCreateOrganization = useCallback(async () => {
|
||||
if (!session?.user || !orgName.trim()) return
|
||||
await createOrganization(orgName.trim(), orgSlug.trim())
|
||||
setCreateOrgDialogOpen(false)
|
||||
setOrgName('')
|
||||
setOrgSlug('')
|
||||
}, [session?.user?.id, orgName, orgSlug])
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/organizations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: orgName.trim(),
|
||||
slug: orgSlug.trim(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create organization: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success || !result.organizationId) {
|
||||
throw new Error('Failed to create organization')
|
||||
}
|
||||
|
||||
// Refresh organization data
|
||||
await loadData()
|
||||
|
||||
setCreateOrgDialogOpen(false)
|
||||
setOrgName('')
|
||||
setOrgSlug('')
|
||||
} catch (error) {
|
||||
logger.error('Failed to create organization', error)
|
||||
}
|
||||
}, [session?.user?.id, orgName, orgSlug, loadData])
|
||||
|
||||
const handleInviteMember = useCallback(async () => {
|
||||
if (!session?.user || !activeOrgId || !inviteEmail.trim()) return
|
||||
@@ -221,15 +230,6 @@ export function TeamManagement() {
|
||||
[subscriptionData?.id, activeOrgId, newSeatCount]
|
||||
)
|
||||
|
||||
const handleOrgInputChange = useCallback((field: string, value: string) => {
|
||||
setOrgFormData({ [field]: value })
|
||||
}, [])
|
||||
|
||||
const handleSaveOrgSettings = useCallback(async () => {
|
||||
if (!activeOrgId || !adminOrOwner) return
|
||||
await updateOrganizationSettings()
|
||||
}, [activeOrgId, adminOrOwner])
|
||||
|
||||
const confirmTeamUpgrade = useCallback(
|
||||
async (seats: number) => {
|
||||
if (!session?.user || !activeOrgId) return
|
||||
@@ -241,10 +241,12 @@ export function TeamManagement() {
|
||||
|
||||
if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) {
|
||||
return (
|
||||
<div className='space-y-2 p-6'>
|
||||
<Skeleton className='h-4 w-full' />
|
||||
<Skeleton className='h-20 w-full' />
|
||||
<Skeleton className='h-4 w-3/4' />
|
||||
<div className='px-6 pt-4 pb-4'>
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-4 w-full' />
|
||||
<Skeleton className='h-20 w-full' />
|
||||
<Skeleton className='h-4 w-3/4' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -269,104 +271,105 @@ export function TeamManagement() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4 p-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h3 className='font-medium text-sm'>Team Management</h3>
|
||||
<div className='px-6 pt-4 pb-4'>
|
||||
<div className='flex flex-col gap-6'>
|
||||
{error && (
|
||||
<Alert variant='destructive' className='rounded-[8px]'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{organizations.length > 1 && (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<select
|
||||
className='h-9 rounded-[8px] border border-input bg-background px-3 py-2 text-xs'
|
||||
value={activeOrganization.id}
|
||||
onChange={(e) => setActiveOrganization(e.target.value)}
|
||||
>
|
||||
{organizations.map((org) => (
|
||||
<option key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Team Usage Overview */}
|
||||
<TeamUsage hasAdminAccess={adminOrOwner} />
|
||||
|
||||
{/* Team Billing Information (only show for Team Plan, not Enterprise) */}
|
||||
{hasTeamPlan && !hasEnterprisePlan && (
|
||||
<div className='rounded-[8px] border bg-blue-50/50 p-4 shadow-xs dark:bg-blue-950/20'>
|
||||
<div className='space-y-3'>
|
||||
<h4 className='font-medium text-sm'>How Team Billing Works</h4>
|
||||
<ul className='ml-4 list-disc space-y-2 text-muted-foreground text-xs'>
|
||||
<li>
|
||||
Your team is billed a minimum of $
|
||||
{(subscriptionData?.seats || 0) *
|
||||
(env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT)}
|
||||
/month for {subscriptionData?.seats || 0} licensed seats
|
||||
</li>
|
||||
<li>All team member usage is pooled together from a shared limit</li>
|
||||
<li>
|
||||
When pooled usage exceeds the limit, all members are blocked from using the
|
||||
service
|
||||
</li>
|
||||
<li>You can increase the usage limit to allow for higher usage</li>
|
||||
<li>
|
||||
Any usage beyond the minimum seat cost is billed as overage at the end of the
|
||||
billing period
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member Invitation Card */}
|
||||
{adminOrOwner && (
|
||||
<MemberInvitationCard
|
||||
inviteEmail={inviteEmail}
|
||||
setInviteEmail={setInviteEmail}
|
||||
isInviting={isInviting}
|
||||
showWorkspaceInvite={showWorkspaceInvite}
|
||||
setShowWorkspaceInvite={setShowWorkspaceInvite}
|
||||
selectedWorkspaces={selectedWorkspaces}
|
||||
userWorkspaces={userWorkspaces}
|
||||
onInviteMember={handleInviteMember}
|
||||
onLoadUserWorkspaces={() => loadUserWorkspaces(session?.user?.id)}
|
||||
onWorkspaceToggle={handleWorkspaceToggle}
|
||||
inviteSuccess={inviteSuccess}
|
||||
availableSeats={Math.max(0, (subscriptionData?.seats || 0) - usedSeats.used)}
|
||||
maxSeats={subscriptionData?.seats || 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Team Seats Overview */}
|
||||
{adminOrOwner && (
|
||||
<TeamSeatsOverview
|
||||
subscriptionData={subscriptionData}
|
||||
isLoadingSubscription={isLoadingSubscription}
|
||||
usedSeats={usedSeats.used}
|
||||
isLoading={isLoading}
|
||||
onConfirmTeamUpgrade={confirmTeamUpgrade}
|
||||
onReduceSeats={handleReduceSeats}
|
||||
onAddSeatDialog={handleAddSeatDialog}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Team Members */}
|
||||
<TeamMembers
|
||||
organization={activeOrganization}
|
||||
currentUserEmail={session?.user?.email ?? ''}
|
||||
isAdminOrOwner={adminOrOwner}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onCancelInvitation={cancelInvitation}
|
||||
/>
|
||||
|
||||
{/* Team Information Section - at bottom of modal */}
|
||||
<div className='mt-12 border-t pt-6'>
|
||||
<div className='space-y-3 text-xs'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Team ID:</span>
|
||||
<span className='font-mono'>{activeOrganization.id}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Created:</span>
|
||||
<span>{new Date(activeOrganization.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Your Role:</span>
|
||||
<span className='font-medium capitalize'>{userRole}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant='destructive' className='rounded-[8px]'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value='members'>Members</TabsTrigger>
|
||||
<TabsTrigger value='usage'>Usage</TabsTrigger>
|
||||
<TabsTrigger value='settings'>Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='members' className='mt-4 space-y-4'>
|
||||
{adminOrOwner && (
|
||||
<MemberInvitationCard
|
||||
inviteEmail={inviteEmail}
|
||||
setInviteEmail={setInviteEmail}
|
||||
isInviting={isInviting}
|
||||
showWorkspaceInvite={showWorkspaceInvite}
|
||||
setShowWorkspaceInvite={setShowWorkspaceInvite}
|
||||
selectedWorkspaces={selectedWorkspaces}
|
||||
userWorkspaces={userWorkspaces}
|
||||
onInviteMember={handleInviteMember}
|
||||
onLoadUserWorkspaces={() => loadUserWorkspaces(session?.user?.id)}
|
||||
onWorkspaceToggle={handleWorkspaceToggle}
|
||||
inviteSuccess={inviteSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{adminOrOwner && (
|
||||
<TeamSeatsOverview
|
||||
subscriptionData={subscriptionData}
|
||||
isLoadingSubscription={isLoadingSubscription}
|
||||
usedSeats={usedSeats.used}
|
||||
isLoading={isLoading}
|
||||
onConfirmTeamUpgrade={confirmTeamUpgrade}
|
||||
onReduceSeats={handleReduceSeats}
|
||||
onAddSeatDialog={handleAddSeatDialog}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TeamMembersList
|
||||
organization={activeOrganization}
|
||||
currentUserEmail={session?.user?.email ?? ''}
|
||||
isAdminOrOwner={adminOrOwner}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
/>
|
||||
|
||||
{adminOrOwner && (activeOrganization.invitations?.length ?? 0) > 0 && (
|
||||
<PendingInvitationsList
|
||||
organization={activeOrganization}
|
||||
onCancelInvitation={cancelInvitation}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='usage' className='mt-4 space-y-4'>
|
||||
<TeamUsage hasAdminAccess={adminOrOwner} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='settings'>
|
||||
<OrganizationSettingsTab
|
||||
organization={activeOrganization}
|
||||
isAdminOrOwner={adminOrOwner}
|
||||
userRole={userRole}
|
||||
orgFormData={orgFormData}
|
||||
onOrgInputChange={handleOrgInputChange}
|
||||
onSaveOrgSettings={handleSaveOrgSettings}
|
||||
isSavingOrgSettings={isSavingOrgSettings}
|
||||
orgSettingsError={orgSettingsError}
|
||||
orgSettingsSuccess={orgSettingsSuccess}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<RemoveMemberDialog
|
||||
open={removeMemberDialog.open}
|
||||
memberName={removeMemberDialog.memberName}
|
||||
@@ -395,7 +398,7 @@ export function TeamManagement() {
|
||||
open={isAddSeatDialogOpen}
|
||||
onOpenChange={setIsAddSeatDialogOpen}
|
||||
title='Add Team Seats'
|
||||
description={`Each seat costs $${env.TEAM_TIER_COST_LIMIT}/month and provides $${env.TEAM_TIER_COST_LIMIT} in monthly inference credits. Adjust the number of licensed seats for your team.`}
|
||||
description={`Each seat costs $${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month and provides $${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT} in monthly inference credits. Adjust the number of licensed seats for your team.`}
|
||||
currentSeats={subscriptionData?.seats || 1}
|
||||
initialSeats={newSeatCount}
|
||||
isLoading={isUpdatingSeats}
|
||||
@@ -405,6 +408,7 @@ export function TeamManagement() {
|
||||
}}
|
||||
confirmButtonText='Update Seats'
|
||||
showCostBreakdown={true}
|
||||
isCancelledAtPeriodEnd={subscriptionData?.cancelAtPeriodEnd}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -22,8 +22,9 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSession, useSubscription } from '@/lib/auth-client'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
@@ -43,7 +44,7 @@ interface PlanFeature {
|
||||
|
||||
export function SubscriptionModal({ open, onOpenChange }: SubscriptionModalProps) {
|
||||
const { data: session } = useSession()
|
||||
const betterAuthSubscription = useSubscription()
|
||||
const { handleUpgrade } = useSubscriptionUpgrade()
|
||||
const { activeOrganization } = useOrganizationStore()
|
||||
const { loadData, getSubscriptionStatus, isLoading } = useSubscriptionStore()
|
||||
|
||||
@@ -56,40 +57,15 @@ export function SubscriptionModal({ open, onOpenChange }: SubscriptionModalProps
|
||||
|
||||
const subscription = getSubscriptionStatus()
|
||||
|
||||
const handleUpgrade = useCallback(
|
||||
const handleUpgradeWithErrorHandling = useCallback(
|
||||
async (targetPlan: 'pro' | 'team') => {
|
||||
if (!session?.user?.id) return
|
||||
|
||||
const subscriptionData = useSubscriptionStore.getState().subscriptionData
|
||||
const currentSubscriptionId = subscriptionData?.stripeSubscriptionId
|
||||
|
||||
let referenceId = session.user.id
|
||||
if (subscription.isTeam && activeOrganization?.id) {
|
||||
referenceId = activeOrganization.id
|
||||
}
|
||||
|
||||
const currentUrl = window.location.origin + window.location.pathname
|
||||
|
||||
try {
|
||||
const upgradeParams: any = {
|
||||
plan: targetPlan,
|
||||
referenceId,
|
||||
successUrl: currentUrl,
|
||||
cancelUrl: currentUrl,
|
||||
seats: targetPlan === 'team' ? 1 : undefined,
|
||||
}
|
||||
|
||||
if (currentSubscriptionId) {
|
||||
upgradeParams.subscriptionId = currentSubscriptionId
|
||||
}
|
||||
|
||||
await betterAuthSubscription.upgrade(upgradeParams)
|
||||
await handleUpgrade(targetPlan)
|
||||
} catch (error) {
|
||||
logger.error('Failed to initiate subscription upgrade:', error)
|
||||
alert('Failed to initiate upgrade. Please try again or contact support.')
|
||||
alert(error instanceof Error ? error.message : 'Unknown error occurred')
|
||||
}
|
||||
},
|
||||
[session?.user?.id, subscription.isTeam, activeOrganization?.id, betterAuthSubscription]
|
||||
[handleUpgrade]
|
||||
)
|
||||
|
||||
const handleContactUs = () => {
|
||||
@@ -124,7 +100,7 @@ export function SubscriptionModal({ open, onOpenChange }: SubscriptionModalProps
|
||||
{ text: 'Unlimited log retention', included: true, icon: Database },
|
||||
],
|
||||
isActive: subscription.isPro && !subscription.isTeam,
|
||||
action: subscription.isFree ? () => handleUpgrade('pro') : null,
|
||||
action: subscription.isFree ? () => handleUpgradeWithErrorHandling('pro') : null,
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
@@ -137,7 +113,7 @@ export function SubscriptionModal({ open, onOpenChange }: SubscriptionModalProps
|
||||
{ text: 'Dedicated Slack channel', included: true, icon: MessageSquare },
|
||||
],
|
||||
isActive: subscription.isTeam,
|
||||
action: !subscription.isTeam ? () => handleUpgrade('team') : null,
|
||||
action: !subscription.isTeam ? () => handleUpgradeWithErrorHandling('team') : null,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
|
||||
@@ -22,7 +22,7 @@ const PLAN_NAMES = {
|
||||
} as const
|
||||
|
||||
interface UsageIndicatorProps {
|
||||
onClick?: (badgeType: 'add' | 'upgrade') => void
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
@@ -39,7 +39,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
// Show skeleton while loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={CONTAINER_STYLES} onClick={() => onClick?.('upgrade')}>
|
||||
<div className={CONTAINER_STYLES} onClick={() => onClick?.()}>
|
||||
<div className='space-y-2'>
|
||||
{/* Plan and usage info skeleton */}
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -67,12 +67,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
: 'free'
|
||||
|
||||
// Determine badge to show
|
||||
const showAddBadge = planType !== 'free' && usage.percentUsed >= 50
|
||||
const badgeText = planType === 'free' ? 'Upgrade' : 'Add'
|
||||
const badgeType = planType === 'free' ? 'upgrade' : 'add'
|
||||
const billingStatus = useSubscriptionStore.getState().getBillingStatus()
|
||||
const isBlocked = billingStatus === 'blocked'
|
||||
const badgeText = isBlocked ? 'Payment Failed' : planType === 'free' ? 'Upgrade' : undefined
|
||||
|
||||
return (
|
||||
<div className={CONTAINER_STYLES} onClick={() => onClick?.(badgeType)}>
|
||||
<div className={CONTAINER_STYLES} onClick={() => onClick?.()}>
|
||||
<div className='space-y-2'>
|
||||
{/* Plan and usage info */}
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -85,17 +85,15 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
>
|
||||
{PLAN_NAMES[planType]}
|
||||
</span>
|
||||
{(showAddBadge || planType === 'free') && (
|
||||
<Badge className={GRADIENT_BADGE_STYLES}>{badgeText}</Badge>
|
||||
)}
|
||||
{badgeText ? <Badge className={GRADIENT_BADGE_STYLES}>{badgeText}</Badge> : null}
|
||||
</div>
|
||||
<span className='text-muted-foreground text-xs tabular-nums'>
|
||||
${usage.current.toFixed(2)} / ${usage.limit}
|
||||
{isBlocked ? 'Payment required' : `$${usage.current.toFixed(2)} / $${usage.limit}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<Progress value={progressPercentage} className='h-2' />
|
||||
{/* Progress Bar with color: yellow for warning, red for full/blocked */}
|
||||
<Progress value={isBlocked ? 100 : progressPercentage} className='h-2' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getKeyboardShortcutText,
|
||||
useGlobalShortcuts,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
@@ -115,7 +116,7 @@ export function Sidebar() {
|
||||
const [isTemplatesLoading, setIsTemplatesLoading] = useState(false)
|
||||
|
||||
// Refs
|
||||
const workflowScrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const workflowScrollAreaRef = useRef<HTMLDivElement | null>(null)
|
||||
const workspaceIdRef = useRef<string>(workspaceId)
|
||||
const routerRef = useRef<ReturnType<typeof useRouter>>(router)
|
||||
const isInitializedRef = useRef<boolean>(false)
|
||||
@@ -930,13 +931,15 @@ export function Sidebar() {
|
||||
}`}
|
||||
>
|
||||
<div className='px-2'>
|
||||
<ScrollArea ref={workflowScrollAreaRef} className='h-[210px]' hideScrollbar={true}>
|
||||
<FolderTree
|
||||
regularWorkflows={regularWorkflows}
|
||||
marketplaceWorkflows={tempWorkflows}
|
||||
isLoading={isLoading}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
/>
|
||||
<ScrollArea className='h-[210px]' hideScrollbar={true}>
|
||||
<div ref={workflowScrollAreaRef}>
|
||||
<FolderTree
|
||||
regularWorkflows={regularWorkflows}
|
||||
marketplaceWorkflows={tempWorkflows}
|
||||
isLoading={isLoading}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
@@ -1003,16 +1006,15 @@ export function Sidebar() {
|
||||
style={{ bottom: `${navigationBottom + SIDEBAR_HEIGHTS.NAVIGATION + SIDEBAR_GAP}px` }} // Navigation height + gap
|
||||
>
|
||||
<UsageIndicator
|
||||
onClick={(badgeType) => {
|
||||
if (badgeType === 'add') {
|
||||
// Open settings modal on subscription tab
|
||||
onClick={() => {
|
||||
const isBlocked = useSubscriptionStore.getState().getBillingStatus() === 'blocked'
|
||||
if (isBlocked) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('open-settings', { detail: { tab: 'subscription' } })
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Open subscription modal for upgrade
|
||||
setShowSubscriptionModal(true)
|
||||
}
|
||||
}}
|
||||
|
||||
8
apps/sim/db/migrations/0079_shocking_shriek.sql
Normal file
8
apps/sim/db/migrations/0079_shocking_shriek.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE "subscription" DROP CONSTRAINT "check_enterprise_metadata";--> statement-breakpoint
|
||||
ALTER TABLE "organization" ADD COLUMN "org_usage_limit" numeric;--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" ALTER COLUMN "current_usage_limit" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" ADD COLUMN IF NOT EXISTS "billing_blocked" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" DROP COLUMN "usage_limit_set_by";--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" DROP COLUMN "billing_period_start";--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" DROP COLUMN "billing_period_end";--> statement-breakpoint
|
||||
ALTER TABLE "subscription" ADD CONSTRAINT "check_enterprise_metadata" CHECK (plan != 'enterprise' OR metadata IS NOT NULL);
|
||||
5818
apps/sim/db/migrations/meta/0079_snapshot.json
Normal file
5818
apps/sim/db/migrations/meta/0079_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -547,6 +547,13 @@
|
||||
"when": 1756168384465,
|
||||
"tag": "0078_supreme_madrox",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 79,
|
||||
"version": "7",
|
||||
"when": 1756246702112,
|
||||
"tag": "0079_shocking_shriek",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -441,21 +441,17 @@ export const userStats = pgTable('user_stats', {
|
||||
totalChatExecutions: integer('total_chat_executions').notNull().default(0),
|
||||
totalTokensUsed: integer('total_tokens_used').notNull().default(0),
|
||||
totalCost: decimal('total_cost').notNull().default('0'),
|
||||
currentUsageLimit: decimal('current_usage_limit')
|
||||
.notNull()
|
||||
.default(DEFAULT_FREE_CREDITS.toString()), // Default $10 for free plan
|
||||
usageLimitSetBy: text('usage_limit_set_by'), // User ID who set the limit (for team admin tracking)
|
||||
currentUsageLimit: decimal('current_usage_limit').default(DEFAULT_FREE_CREDITS.toString()), // Default $10 for free plan, null for team/enterprise
|
||||
usageLimitUpdatedAt: timestamp('usage_limit_updated_at').defaultNow(),
|
||||
// Billing period tracking
|
||||
currentPeriodCost: decimal('current_period_cost').notNull().default('0'), // Usage in current billing period
|
||||
billingPeriodStart: timestamp('billing_period_start').defaultNow(), // When current billing period started
|
||||
billingPeriodEnd: timestamp('billing_period_end'), // When current billing period ends
|
||||
lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period
|
||||
// Copilot usage tracking
|
||||
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
|
||||
totalCopilotTokens: integer('total_copilot_tokens').notNull().default(0),
|
||||
totalCopilotCalls: integer('total_copilot_calls').notNull().default(0),
|
||||
lastActive: timestamp('last_active').notNull().defaultNow(),
|
||||
billingBlocked: boolean('billing_blocked').notNull().default(false),
|
||||
})
|
||||
|
||||
export const customTools = pgTable('custom_tools', {
|
||||
@@ -494,7 +490,7 @@ export const subscription = pgTable(
|
||||
),
|
||||
enterpriseMetadataCheck: check(
|
||||
'check_enterprise_metadata',
|
||||
sql`plan != 'enterprise' OR (metadata IS NOT NULL AND (metadata->>'perSeatAllowance' IS NOT NULL OR metadata->>'totalAllowance' IS NOT NULL))`
|
||||
sql`plan != 'enterprise' OR metadata IS NOT NULL`
|
||||
),
|
||||
})
|
||||
)
|
||||
@@ -552,6 +548,7 @@ export const organization = pgTable('organization', {
|
||||
slug: text('slug').notNull(),
|
||||
logo: text('logo'),
|
||||
metadata: json('metadata'),
|
||||
orgUsageLimit: decimal('org_usage_limit'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import type { SubscriptionFeatures } from '@/lib/billing/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('useSubscriptionState')
|
||||
@@ -25,7 +24,6 @@ interface SubscriptionState {
|
||||
status: string | null
|
||||
seats: number | null
|
||||
metadata: any | null
|
||||
features: SubscriptionFeatures
|
||||
usage: UsageData
|
||||
}
|
||||
|
||||
@@ -82,12 +80,6 @@ export function useSubscriptionState() {
|
||||
metadata: data?.metadata,
|
||||
},
|
||||
|
||||
features: {
|
||||
sharingEnabled: data?.features?.sharingEnabled ?? false,
|
||||
multiplayerEnabled: data?.features?.multiplayerEnabled ?? false,
|
||||
workspaceCollaborationEnabled: data?.features?.workspaceCollaborationEnabled ?? false,
|
||||
},
|
||||
|
||||
usage: {
|
||||
current: data?.usage?.current ?? 0,
|
||||
limit: data?.usage?.limit ?? DEFAULT_FREE_CREDITS,
|
||||
@@ -107,10 +99,6 @@ export function useSubscriptionState() {
|
||||
error,
|
||||
refetch,
|
||||
|
||||
hasFeature: (feature: keyof SubscriptionFeatures) => {
|
||||
return data?.features?.[feature] ?? false
|
||||
},
|
||||
|
||||
isAtLeastPro: () => {
|
||||
return data?.isPro || data?.isTeam || data?.isEnterprise || false
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import type { auth } from '@/lib/auth'
|
||||
import { env, getEnv } from '@/lib/env'
|
||||
import { isDev, isProd } from '@/lib/environment'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { SessionContext, type SessionHookResult } from '@/lib/session-context'
|
||||
|
||||
export function getBaseURL() {
|
||||
@@ -59,19 +59,6 @@ export function useSession(): SessionHookResult {
|
||||
export const { useActiveOrganization } = client
|
||||
|
||||
export const useSubscription = () => {
|
||||
// In development, provide mock implementations
|
||||
if (isDev) {
|
||||
return {
|
||||
list: async () => ({ data: [] }),
|
||||
upgrade: async () => ({
|
||||
error: { message: 'Subscriptions are disabled in development mode' },
|
||||
}),
|
||||
cancel: async () => ({ data: null }),
|
||||
restore: async () => ({ data: null }),
|
||||
}
|
||||
}
|
||||
|
||||
// In production, use the real implementation
|
||||
return {
|
||||
list: client.subscription?.list,
|
||||
upgrade: client.subscription?.upgrade,
|
||||
|
||||
@@ -20,7 +20,16 @@ import {
|
||||
renderPasswordResetEmail,
|
||||
} from '@/components/emails/render-email'
|
||||
import { getBaseURL } from '@/lib/auth-client'
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
|
||||
import { handleNewUser } from '@/lib/billing/core/usage'
|
||||
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
|
||||
import { getPlans } from '@/lib/billing/plans'
|
||||
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
|
||||
import {
|
||||
handleInvoiceFinalized,
|
||||
handleInvoicePaymentFailed,
|
||||
handleInvoicePaymentSucceeded,
|
||||
} from '@/lib/billing/webhooks/invoices'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/email/validation'
|
||||
@@ -34,10 +43,7 @@ const logger = createLogger('Auth')
|
||||
|
||||
// Only initialize Stripe if the key is provided
|
||||
// This allows local development without a Stripe account
|
||||
const validStripeKey =
|
||||
env.STRIPE_SECRET_KEY &&
|
||||
env.STRIPE_SECRET_KEY.trim() !== '' &&
|
||||
env.STRIPE_SECRET_KEY !== 'placeholder'
|
||||
const validStripeKey = env.STRIPE_SECRET_KEY
|
||||
|
||||
let stripeClient = null
|
||||
if (validStripeKey) {
|
||||
@@ -46,6 +52,121 @@ if (validStripeKey) {
|
||||
})
|
||||
}
|
||||
|
||||
function isEnterpriseMetadata(value: unknown): value is EnterpriseSubscriptionMetadata {
|
||||
return (
|
||||
!!value &&
|
||||
typeof (value as any).plan === 'string' &&
|
||||
(value as any).plan.toLowerCase() === 'enterprise'
|
||||
)
|
||||
}
|
||||
|
||||
async function handleManualEnterpriseSubscription(event: Stripe.Event) {
|
||||
const stripeSubscription = event.data.object as Stripe.Subscription
|
||||
|
||||
const metaPlan = (stripeSubscription.metadata?.plan as string | undefined)?.toLowerCase() || ''
|
||||
|
||||
if (metaPlan !== 'enterprise') {
|
||||
logger.info('[subscription.created] Skipping non-enterprise subscription', {
|
||||
subscriptionId: stripeSubscription.id,
|
||||
plan: metaPlan || 'unknown',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const stripeCustomerId = stripeSubscription.customer as string
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
logger.error('[subscription.created] Missing Stripe customer ID', {
|
||||
subscriptionId: stripeSubscription.id,
|
||||
})
|
||||
throw new Error('Missing Stripe customer ID on subscription')
|
||||
}
|
||||
|
||||
const metadata = stripeSubscription.metadata || {}
|
||||
|
||||
const referenceId =
|
||||
typeof metadata.referenceId === 'string' && metadata.referenceId.length > 0
|
||||
? metadata.referenceId
|
||||
: null
|
||||
|
||||
if (!referenceId) {
|
||||
logger.error('[subscription.created] Unable to resolve referenceId', {
|
||||
subscriptionId: stripeSubscription.id,
|
||||
stripeCustomerId,
|
||||
})
|
||||
throw new Error('Unable to resolve referenceId for subscription')
|
||||
}
|
||||
|
||||
const firstItem = stripeSubscription.items?.data?.[0]
|
||||
const seats = typeof firstItem?.quantity === 'number' ? firstItem.quantity : null
|
||||
|
||||
if (!isEnterpriseMetadata(metadata)) {
|
||||
logger.error('[subscription.created] Invalid enterprise metadata shape', {
|
||||
subscriptionId: stripeSubscription.id,
|
||||
metadata,
|
||||
})
|
||||
throw new Error('Invalid enterprise metadata for subscription')
|
||||
}
|
||||
const enterpriseMetadata = metadata
|
||||
const metadataJson: Record<string, unknown> = { ...enterpriseMetadata }
|
||||
|
||||
const subscriptionRow = {
|
||||
id: crypto.randomUUID(),
|
||||
plan: 'enterprise',
|
||||
referenceId,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
status: stripeSubscription.status || null,
|
||||
periodStart: stripeSubscription.current_period_start
|
||||
? new Date(stripeSubscription.current_period_start * 1000)
|
||||
: null,
|
||||
periodEnd: stripeSubscription.current_period_end
|
||||
? new Date(stripeSubscription.current_period_end * 1000)
|
||||
: null,
|
||||
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null,
|
||||
seats,
|
||||
trialStart: stripeSubscription.trial_start
|
||||
? new Date(stripeSubscription.trial_start * 1000)
|
||||
: null,
|
||||
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
|
||||
metadata: metadataJson,
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ id: schema.subscription.id })
|
||||
.from(schema.subscription)
|
||||
.where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id))
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(schema.subscription)
|
||||
.set({
|
||||
plan: subscriptionRow.plan,
|
||||
referenceId: subscriptionRow.referenceId,
|
||||
stripeCustomerId: subscriptionRow.stripeCustomerId,
|
||||
status: subscriptionRow.status,
|
||||
periodStart: subscriptionRow.periodStart,
|
||||
periodEnd: subscriptionRow.periodEnd,
|
||||
cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd,
|
||||
seats: subscriptionRow.seats,
|
||||
trialStart: subscriptionRow.trialStart,
|
||||
trialEnd: subscriptionRow.trialEnd,
|
||||
metadata: subscriptionRow.metadata,
|
||||
})
|
||||
.where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id))
|
||||
} else {
|
||||
await db.insert(schema.subscription).values(subscriptionRow)
|
||||
}
|
||||
|
||||
logger.info('[subscription.created] Upserted subscription', {
|
||||
subscriptionId: subscriptionRow.id,
|
||||
referenceId: subscriptionRow.referenceId,
|
||||
plan: subscriptionRow.plan,
|
||||
status: subscriptionRow.status,
|
||||
})
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: getBaseURL(),
|
||||
trustedOrigins: [
|
||||
@@ -1152,19 +1273,16 @@ export const auth = betterAuth({
|
||||
stripeClient,
|
||||
stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
|
||||
createCustomerOnSignUp: true,
|
||||
onCustomerCreate: async ({ stripeCustomer, user }, request) => {
|
||||
logger.info('Stripe customer created', {
|
||||
customerId: stripeCustomer.id,
|
||||
onCustomerCreate: async ({ stripeCustomer, user }) => {
|
||||
logger.info('[onCustomerCreate] Stripe customer created', {
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
userId: user.id,
|
||||
})
|
||||
|
||||
// Initialize usage limits for new user
|
||||
try {
|
||||
const { initializeUserUsageLimit } = await import('@/lib/billing')
|
||||
await initializeUserUsageLimit(user.id)
|
||||
logger.info('Usage limits initialized for new user', { userId: user.id })
|
||||
await handleNewUser(user.id)
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize usage limits for new user', {
|
||||
logger.error('[onCustomerCreate] Failed to handle new user setup', {
|
||||
userId: user.id,
|
||||
error,
|
||||
})
|
||||
@@ -1172,61 +1290,11 @@ export const auth = betterAuth({
|
||||
},
|
||||
subscription: {
|
||||
enabled: true,
|
||||
plans: [
|
||||
{
|
||||
name: 'free',
|
||||
priceId: env.STRIPE_FREE_PRICE_ID || '',
|
||||
limits: {
|
||||
cost: env.FREE_TIER_COST_LIMIT ?? DEFAULT_FREE_CREDITS,
|
||||
sharingEnabled: 0,
|
||||
multiplayerEnabled: 0,
|
||||
workspaceCollaborationEnabled: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pro',
|
||||
priceId: env.STRIPE_PRO_PRICE_ID || '',
|
||||
limits: {
|
||||
cost: env.PRO_TIER_COST_LIMIT ?? 20,
|
||||
sharingEnabled: 1,
|
||||
multiplayerEnabled: 0,
|
||||
workspaceCollaborationEnabled: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'team',
|
||||
priceId: env.STRIPE_TEAM_PRICE_ID || '',
|
||||
limits: {
|
||||
cost: env.TEAM_TIER_COST_LIMIT ?? 40, // $40 per seat
|
||||
sharingEnabled: 1,
|
||||
multiplayerEnabled: 1,
|
||||
workspaceCollaborationEnabled: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
authorizeReference: async ({ user, referenceId, action }) => {
|
||||
// User can always manage their own subscriptions
|
||||
if (referenceId === user.id) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if referenceId is an organizationId the user has admin rights to
|
||||
const members = await db
|
||||
.select()
|
||||
.from(schema.member)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.member.userId, user.id),
|
||||
eq(schema.member.organizationId, referenceId)
|
||||
)
|
||||
)
|
||||
|
||||
const member = members[0]
|
||||
|
||||
// Allow if the user is an owner or admin of the organization
|
||||
return member?.role === 'owner' || member?.role === 'admin'
|
||||
plans: getPlans(),
|
||||
authorizeReference: async ({ user, referenceId }) => {
|
||||
return await authorizeSubscriptionReference(user.id, referenceId)
|
||||
},
|
||||
getCheckoutSessionParams: async ({ user, plan, subscription }, request) => {
|
||||
getCheckoutSessionParams: async ({ plan, subscription }) => {
|
||||
if (plan.name === 'team') {
|
||||
return {
|
||||
params: {
|
||||
@@ -1253,127 +1321,128 @@ export const auth = betterAuth({
|
||||
}
|
||||
},
|
||||
onSubscriptionComplete: async ({
|
||||
event,
|
||||
stripeSubscription,
|
||||
subscription,
|
||||
}: {
|
||||
event: Stripe.Event
|
||||
stripeSubscription: Stripe.Subscription
|
||||
subscription: any
|
||||
}) => {
|
||||
logger.info('Subscription created', {
|
||||
logger.info('[onSubscriptionComplete] Subscription created', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
})
|
||||
|
||||
// Auto-create organization for team plan purchases
|
||||
// Sync usage limits for the new subscription
|
||||
try {
|
||||
const { handleTeamPlanOrganization } = await import(
|
||||
'@/lib/billing/team-management'
|
||||
)
|
||||
await handleTeamPlanOrganization(subscription)
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle team plan organization creation', {
|
||||
logger.error('[onSubscriptionComplete] Failed to sync usage limits', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize billing period and sync usage limits
|
||||
try {
|
||||
const { initializeBillingPeriod } = await import(
|
||||
'@/lib/billing/core/billing-periods'
|
||||
)
|
||||
const { syncSubscriptionUsageLimits } = await import(
|
||||
'@/lib/billing/team-management'
|
||||
)
|
||||
|
||||
// Sync usage limits for user or organization members
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
|
||||
// Initialize billing period for new subscription using Stripe dates
|
||||
if (subscription.plan !== 'free') {
|
||||
const stripeStart = new Date(stripeSubscription.current_period_start * 1000)
|
||||
const stripeEnd = new Date(stripeSubscription.current_period_end * 1000)
|
||||
|
||||
await initializeBillingPeriod(subscription.referenceId, stripeStart, stripeEnd)
|
||||
logger.info(
|
||||
'Billing period initialized for new subscription with Stripe dates',
|
||||
{
|
||||
referenceId: subscription.referenceId,
|
||||
billingStart: stripeStart,
|
||||
billingEnd: stripeEnd,
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Failed to sync usage limits or initialize billing period after subscription creation',
|
||||
{
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
onSubscriptionUpdate: async ({
|
||||
event,
|
||||
subscription,
|
||||
}: {
|
||||
event: Stripe.Event
|
||||
subscription: any
|
||||
}) => {
|
||||
logger.info('Subscription updated', {
|
||||
logger.info('[onSubscriptionUpdate] Subscription updated', {
|
||||
subscriptionId: subscription.id,
|
||||
status: subscription.status,
|
||||
plan: subscription.plan,
|
||||
})
|
||||
|
||||
// Auto-create organization for team plan upgrades (free -> team)
|
||||
try {
|
||||
const { handleTeamPlanOrganization } = await import(
|
||||
'@/lib/billing/team-management'
|
||||
)
|
||||
await handleTeamPlanOrganization(subscription)
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle team plan organization creation on update', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
// Sync usage limits for the user/organization
|
||||
try {
|
||||
const { syncSubscriptionUsageLimits } = await import(
|
||||
'@/lib/billing/team-management'
|
||||
)
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync usage limits after subscription update', {
|
||||
logger.error('[onSubscriptionUpdate] Failed to sync usage limits', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
},
|
||||
onSubscriptionDeleted: async ({
|
||||
event,
|
||||
stripeSubscription,
|
||||
subscription,
|
||||
}: {
|
||||
event: Stripe.Event
|
||||
stripeSubscription: Stripe.Subscription
|
||||
subscription: any
|
||||
}) => {
|
||||
logger.info('Subscription deleted', {
|
||||
logger.info('[onSubscriptionDeleted] Subscription deleted', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
// Reset usage limits back to free tier defaults
|
||||
try {
|
||||
// This will sync limits based on the now-inactive subscription (defaulting to free tier)
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
|
||||
logger.info('[onSubscriptionDeleted] Reset usage limits to free tier', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('[onSubscriptionDeleted] Failed to reset usage limits', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
onEvent: async (event: Stripe.Event) => {
|
||||
logger.info('[onEvent] Received Stripe webhook', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
})
|
||||
|
||||
try {
|
||||
// Handle invoice events
|
||||
switch (event.type) {
|
||||
case 'invoice.payment_succeeded': {
|
||||
await handleInvoicePaymentSucceeded(event)
|
||||
break
|
||||
}
|
||||
case 'invoice.payment_failed': {
|
||||
await handleInvoicePaymentFailed(event)
|
||||
break
|
||||
}
|
||||
case 'invoice.finalized': {
|
||||
await handleInvoiceFinalized(event)
|
||||
break
|
||||
}
|
||||
case 'customer.subscription.created': {
|
||||
await handleManualEnterpriseSubscription(event)
|
||||
break
|
||||
}
|
||||
default:
|
||||
logger.info('[onEvent] Ignoring unsupported webhook event', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
logger.info('[onEvent] Successfully processed webhook', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('[onEvent] Failed to process webhook', {
|
||||
eventId: event.id,
|
||||
eventType: event.type,
|
||||
error,
|
||||
})
|
||||
throw error // Re-throw to signal webhook failure to Stripe
|
||||
}
|
||||
},
|
||||
}),
|
||||
// Add organization plugin as a separate entry in the plugins array
|
||||
organization({
|
||||
@@ -1405,10 +1474,12 @@ export const auth = betterAuth({
|
||||
)
|
||||
)
|
||||
|
||||
const teamSubscription = subscriptions.find((sub) => sub.plan === 'team')
|
||||
const teamOrEnterpriseSubscription = subscriptions.find(
|
||||
(sub) => sub.plan === 'team' || sub.plan === 'enterprise'
|
||||
)
|
||||
|
||||
if (!teamSubscription) {
|
||||
throw new Error('No active team subscription for this organization')
|
||||
if (!teamOrEnterpriseSubscription) {
|
||||
throw new Error('No active team or enterprise subscription for this organization')
|
||||
}
|
||||
|
||||
const members = await db
|
||||
@@ -1427,7 +1498,7 @@ export const auth = betterAuth({
|
||||
)
|
||||
|
||||
const totalCount = members.length + pendingInvites.length
|
||||
const seatLimit = teamSubscription.seats || 1
|
||||
const seatLimit = teamOrEnterpriseSubscription.seats || 1
|
||||
|
||||
if (totalCount >= seatLimit) {
|
||||
throw new Error(`Organization has reached its seat limit of ${seatLimit}`)
|
||||
@@ -1463,8 +1534,8 @@ export const auth = betterAuth({
|
||||
}
|
||||
},
|
||||
organizationCreation: {
|
||||
afterCreate: async ({ organization, member, user }) => {
|
||||
logger.info('Organization created', {
|
||||
afterCreate: async ({ organization, user }) => {
|
||||
logger.info('[organizationCreation.afterCreate] Organization created', {
|
||||
organizationId: organization.id,
|
||||
creatorId: user.id,
|
||||
})
|
||||
|
||||
28
apps/sim/lib/billing/authorization.ts
Normal file
28
apps/sim/lib/billing/authorization.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
|
||||
/**
|
||||
* Check if a user is authorized to manage billing for a given reference ID
|
||||
* Reference ID can be either a user ID (individual subscription) or organization ID (team subscription)
|
||||
*/
|
||||
export async function authorizeSubscriptionReference(
|
||||
userId: string,
|
||||
referenceId: string
|
||||
): Promise<boolean> {
|
||||
// User can always manage their own subscriptions
|
||||
if (referenceId === userId) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if referenceId is an organizationId the user has admin rights to
|
||||
const members = await db
|
||||
.select()
|
||||
.from(schema.member)
|
||||
.where(and(eq(schema.member.userId, userId), eq(schema.member.organizationId, referenceId)))
|
||||
|
||||
const member = members[0]
|
||||
|
||||
// Allow if the user is an owner or admin of the organization
|
||||
return member?.role === 'owner' || member?.role === 'admin'
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { getUserUsageLimit } from '@/lib/billing/core/usage'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { userStats } from '@/db/schema'
|
||||
import { member, organization, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('UsageMonitor')
|
||||
|
||||
@@ -44,7 +45,7 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
|
||||
}
|
||||
}
|
||||
|
||||
// Get usage limit from user_stats (new method)
|
||||
// Get usage limit from user_stats (per-user cap)
|
||||
const limit = await getUserUsageLimit(userId)
|
||||
logger.info('Using stored usage limit', { userId, limit })
|
||||
|
||||
@@ -70,11 +71,71 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
|
||||
)
|
||||
|
||||
// Calculate percentage used
|
||||
const percentUsed = Math.min(Math.round((currentUsage / limit) * 100), 100)
|
||||
const percentUsed = Math.min(Math.floor((currentUsage / limit) * 100), 100)
|
||||
|
||||
// Check if usage exceeds threshold or limit
|
||||
const isWarning = percentUsed >= WARNING_THRESHOLD && percentUsed < 100
|
||||
const isExceeded = currentUsage >= limit
|
||||
// Check org-level cap for team/enterprise pooled usage
|
||||
let isExceeded = currentUsage >= limit
|
||||
let isWarning = percentUsed >= WARNING_THRESHOLD && percentUsed < 100
|
||||
try {
|
||||
const memberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
if (memberships.length > 0) {
|
||||
for (const m of memberships) {
|
||||
const orgRows = await db
|
||||
.select({ id: organization.id, orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, m.organizationId))
|
||||
.limit(1)
|
||||
if (orgRows.length) {
|
||||
const org = orgRows[0]
|
||||
// Sum pooled usage
|
||||
const teamMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, org.id))
|
||||
|
||||
// Get all team member usage in a single query to avoid N+1
|
||||
let pooledUsage = 0
|
||||
if (teamMembers.length > 0) {
|
||||
const memberIds = teamMembers.map((tm) => tm.userId)
|
||||
const allMemberStats = await db
|
||||
.select({ current: userStats.currentPeriodCost, total: userStats.totalCost })
|
||||
.from(userStats)
|
||||
.where(inArray(userStats.userId, memberIds))
|
||||
|
||||
for (const stats of allMemberStats) {
|
||||
pooledUsage += Number.parseFloat(
|
||||
stats.current?.toString() || stats.total.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
// Determine org cap
|
||||
let orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0
|
||||
if (!orgCap || Number.isNaN(orgCap)) {
|
||||
// Fall back to minimum billing amount from Stripe subscription
|
||||
const orgSub = await getOrganizationSubscription(org.id)
|
||||
if (orgSub?.seats) {
|
||||
const { basePrice } = getPlanPricing(orgSub.plan, orgSub)
|
||||
orgCap = (orgSub.seats || 1) * basePrice
|
||||
} else {
|
||||
// If no subscription, use team default
|
||||
const { basePrice } = getPlanPricing('team')
|
||||
orgCap = basePrice // Default to 1 seat minimum
|
||||
}
|
||||
}
|
||||
if (pooledUsage >= orgCap) {
|
||||
isExceeded = true
|
||||
isWarning = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error checking organization usage limits', { error, userId })
|
||||
}
|
||||
|
||||
logger.info('Final usage statistics', {
|
||||
userId,
|
||||
@@ -193,6 +254,28 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{
|
||||
|
||||
logger.info('Server-side checking usage limits for user', { userId })
|
||||
|
||||
// Hard block if billing is flagged as blocked
|
||||
const stats = await db
|
||||
.select({
|
||||
blocked: userStats.billingBlocked,
|
||||
current: userStats.currentPeriodCost,
|
||||
total: userStats.totalCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
if (stats.length > 0 && stats[0].blocked) {
|
||||
const currentUsage = Number.parseFloat(
|
||||
stats[0].current?.toString() || stats[0].total.toString()
|
||||
)
|
||||
return {
|
||||
isExceeded: true,
|
||||
currentUsage,
|
||||
limit: 0,
|
||||
message: 'Billing issue detected. Please update your payment method to continue.',
|
||||
}
|
||||
}
|
||||
|
||||
// Get usage data using the same function we use for client-side
|
||||
const usageData = await checkUsageStatus(userId)
|
||||
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default free credits (in dollars) for new users
|
||||
* Fallback free credits (in dollars) when env var is not set
|
||||
*/
|
||||
export const DEFAULT_FREE_CREDITS = 10
|
||||
|
||||
/**
|
||||
* Default per-user minimum limits (in dollars) for paid plans when env vars are absent
|
||||
*/
|
||||
export const DEFAULT_PRO_TIER_COST_LIMIT = 20
|
||||
export const DEFAULT_TEAM_TIER_COST_LIMIT = 40
|
||||
export const DEFAULT_ENTERPRISE_TIER_COST_LIMIT = 200
|
||||
|
||||
/**
|
||||
* Base charge applied to every workflow execution
|
||||
* This charge is applied regardless of whether the workflow uses AI models
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
calculateBillingPeriod,
|
||||
calculateNextBillingPeriod,
|
||||
} from '@/lib/billing/core/billing-periods'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Billing Period Calculations', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// Set consistent date for testing
|
||||
vi.setSystemTime(new Date('2024-07-06T10:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('calculateBillingPeriod', () => {
|
||||
it.concurrent('calculates current period from subscription dates when within period', () => {
|
||||
vi.setSystemTime(new Date('2024-01-20T00:00:00Z')) // Within the subscription period
|
||||
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
const subscriptionEnd = new Date('2024-02-15T00:00:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(subscriptionStart, subscriptionEnd)
|
||||
|
||||
expect(period.start).toEqual(subscriptionStart)
|
||||
expect(period.end).toEqual(subscriptionEnd)
|
||||
})
|
||||
|
||||
it.concurrent('calculates next period when current period has ended', () => {
|
||||
vi.setSystemTime(new Date('2024-03-01T00:00:00Z'))
|
||||
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
const subscriptionEnd = new Date('2024-02-15T00:00:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(subscriptionStart, subscriptionEnd)
|
||||
|
||||
expect(period.start).toEqual(subscriptionEnd)
|
||||
// Expect month-based calculation: Feb 15 + 1 month = Mar 15
|
||||
expect(period.end.getUTCFullYear()).toBe(2024)
|
||||
expect(period.end.getUTCMonth()).toBe(2) // March (0-indexed)
|
||||
expect(period.end.getUTCDate()).toBe(15)
|
||||
})
|
||||
|
||||
it.concurrent('calculates monthly periods from subscription start date', () => {
|
||||
vi.setSystemTime(new Date('2024-01-20T00:00:00Z'))
|
||||
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(subscriptionStart)
|
||||
|
||||
expect(period.start).toEqual(subscriptionStart)
|
||||
expect(period.end).toEqual(new Date('2024-02-15T00:00:00Z'))
|
||||
})
|
||||
|
||||
it.concurrent('advances periods when past end date', () => {
|
||||
vi.setSystemTime(new Date('2024-03-20T00:00:00Z'))
|
||||
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(subscriptionStart)
|
||||
|
||||
expect(period.start).toEqual(new Date('2024-03-15T00:00:00Z'))
|
||||
expect(period.end).toEqual(new Date('2024-04-15T00:00:00Z'))
|
||||
})
|
||||
|
||||
it.concurrent('falls back to calendar month when no subscription data', () => {
|
||||
vi.setSystemTime(new Date('2024-07-06T10:00:00Z'))
|
||||
|
||||
const period = calculateBillingPeriod()
|
||||
|
||||
expect(period.start.getUTCFullYear()).toBe(2024)
|
||||
expect(period.start.getUTCMonth()).toBe(6) // July (0-indexed)
|
||||
expect(period.start.getUTCDate()).toBe(1)
|
||||
expect(period.end.getUTCFullYear()).toBe(2024)
|
||||
expect(period.end.getUTCMonth()).toBe(6) // July (0-indexed)
|
||||
expect(period.end.getUTCDate()).toBe(31)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateNextBillingPeriod', () => {
|
||||
it.concurrent('calculates next period from given end date', () => {
|
||||
const periodEnd = new Date('2024-02-15T00:00:00Z')
|
||||
|
||||
const nextPeriod = calculateNextBillingPeriod(periodEnd)
|
||||
|
||||
expect(nextPeriod.start).toEqual(periodEnd)
|
||||
expect(nextPeriod.end.getUTCFullYear()).toBe(2024)
|
||||
expect(nextPeriod.end.getUTCMonth()).toBe(2) // March (0-indexed)
|
||||
expect(nextPeriod.end.getUTCDate()).toBe(15)
|
||||
})
|
||||
|
||||
it.concurrent('handles month transitions correctly', () => {
|
||||
const periodEnd = new Date('2024-01-31T00:00:00Z')
|
||||
|
||||
const nextPeriod = calculateNextBillingPeriod(periodEnd)
|
||||
|
||||
expect(nextPeriod.start).toEqual(periodEnd)
|
||||
// JavaScript's setUTCMonth handles overflow: Jan 31 + 1 month = Mar 2 (Feb 29 + 2 days in 2024)
|
||||
expect(nextPeriod.end.getUTCMonth()).toBe(2) // March (0-indexed) due to overflow
|
||||
})
|
||||
})
|
||||
|
||||
describe('Period Alignment Scenarios', () => {
|
||||
it.concurrent('aligns with mid-month subscription perfectly', () => {
|
||||
vi.setSystemTime(new Date('2024-03-20T00:00:00Z')) // Within the subscription period
|
||||
|
||||
const midMonthStart = new Date('2024-03-15T10:30:00Z')
|
||||
const midMonthEnd = new Date('2024-04-15T10:30:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(midMonthStart, midMonthEnd)
|
||||
|
||||
expect(period.start.getTime()).toBe(midMonthStart.getTime())
|
||||
expect(period.end.getTime()).toBe(midMonthEnd.getTime())
|
||||
})
|
||||
|
||||
it.concurrent('handles annual subscriptions correctly', () => {
|
||||
vi.setSystemTime(new Date('2024-06-15T00:00:00Z')) // Within the annual subscription period
|
||||
|
||||
const annualStart = new Date('2024-01-01T00:00:00Z')
|
||||
const annualEnd = new Date('2025-01-01T00:00:00Z')
|
||||
|
||||
const period = calculateBillingPeriod(annualStart, annualEnd)
|
||||
|
||||
expect(period.start.getTime()).toBe(annualStart.getTime())
|
||||
expect(period.end.getTime()).toBe(annualEnd.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Billing Check Scenarios', () => {
|
||||
it.concurrent('identifies subscriptions ending today', () => {
|
||||
const today = new Date('2024-07-06T00:00:00Z')
|
||||
vi.setSystemTime(today)
|
||||
|
||||
const endingToday = new Date(today)
|
||||
const shouldBill = endingToday.toDateString() === today.toDateString()
|
||||
|
||||
expect(shouldBill).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('excludes subscriptions ending tomorrow', () => {
|
||||
const today = new Date('2024-07-06T00:00:00Z')
|
||||
vi.setSystemTime(today)
|
||||
|
||||
const endingTomorrow = new Date(today)
|
||||
endingTomorrow.setUTCDate(endingTomorrow.getUTCDate() + 1)
|
||||
|
||||
const shouldBill = endingTomorrow.toDateString() === today.toDateString()
|
||||
|
||||
expect(shouldBill).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('excludes subscriptions that ended yesterday', () => {
|
||||
const today = new Date('2024-07-06T00:00:00Z')
|
||||
vi.setSystemTime(today)
|
||||
|
||||
const endedYesterday = new Date(today)
|
||||
endedYesterday.setUTCDate(endedYesterday.getUTCDate() - 1)
|
||||
|
||||
const shouldBill = endedYesterday.toDateString() === today.toDateString()
|
||||
|
||||
expect(shouldBill).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,276 +0,0 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member, subscription, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('BillingPeriodManager')
|
||||
|
||||
/**
|
||||
* Calculate billing period dates based on subscription for proper Stripe alignment
|
||||
* Supports both subscription start date and full period alignment
|
||||
*/
|
||||
export function calculateBillingPeriod(
|
||||
subscriptionPeriodStart?: Date,
|
||||
subscriptionPeriodEnd?: Date
|
||||
): {
|
||||
start: Date
|
||||
end: Date
|
||||
} {
|
||||
const now = new Date()
|
||||
|
||||
// If we have both subscription dates, use them for perfect alignment
|
||||
if (subscriptionPeriodStart && subscriptionPeriodEnd) {
|
||||
const start = new Date(subscriptionPeriodStart)
|
||||
const end = new Date(subscriptionPeriodEnd)
|
||||
|
||||
// If we're past the current period, calculate the next period using calendar months
|
||||
if (now >= end) {
|
||||
const newStart = new Date(end)
|
||||
const newEnd = new Date(end)
|
||||
|
||||
// Use UTC methods to avoid timezone issues
|
||||
newEnd.setUTCMonth(newEnd.getUTCMonth() + 1)
|
||||
|
||||
logger.info('Calculated next billing period from subscription dates', {
|
||||
originalStart: subscriptionPeriodStart,
|
||||
originalEnd: subscriptionPeriodEnd,
|
||||
newStart,
|
||||
newEnd,
|
||||
})
|
||||
|
||||
return { start: newStart, end: newEnd }
|
||||
}
|
||||
|
||||
logger.info('Using current subscription billing period', {
|
||||
start,
|
||||
end,
|
||||
})
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
// If we only have subscription start date, calculate monthly periods from that date
|
||||
if (subscriptionPeriodStart) {
|
||||
const start = new Date(subscriptionPeriodStart)
|
||||
const end = new Date(start)
|
||||
|
||||
// Add one month to start date using UTC to avoid timezone issues
|
||||
end.setUTCMonth(end.getUTCMonth() + 1)
|
||||
|
||||
// If we're past the end date, calculate the current period
|
||||
while (end <= now) {
|
||||
start.setUTCMonth(start.getUTCMonth() + 1)
|
||||
end.setUTCMonth(end.getUTCMonth() + 1)
|
||||
}
|
||||
|
||||
logger.info('Calculated billing period from subscription start date', {
|
||||
subscriptionStart: subscriptionPeriodStart,
|
||||
currentPeriodStart: start,
|
||||
currentPeriodEnd: end,
|
||||
})
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
// Fallback: Default monthly billing period (1st to last day of month)
|
||||
// This should only be used for users without proper subscription data
|
||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
||||
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0, 23, 59, 59, 999))
|
||||
|
||||
logger.warn('Using fallback calendar month billing period', {
|
||||
start,
|
||||
end,
|
||||
})
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next billing period starting from a given period end date
|
||||
*/
|
||||
export function calculateNextBillingPeriod(periodEnd: Date): {
|
||||
start: Date
|
||||
end: Date
|
||||
} {
|
||||
const start = new Date(periodEnd)
|
||||
const end = new Date(start)
|
||||
|
||||
// Add one month for the next period using UTC to avoid timezone issues
|
||||
end.setUTCMonth(end.getUTCMonth() + 1)
|
||||
|
||||
logger.info('Calculated next billing period', {
|
||||
previousPeriodEnd: periodEnd,
|
||||
nextPeriodStart: start,
|
||||
nextPeriodEnd: end,
|
||||
})
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize billing period for a user based on their subscription
|
||||
* Can optionally accept Stripe subscription dates to ensure proper alignment
|
||||
*/
|
||||
export async function initializeBillingPeriod(
|
||||
userId: string,
|
||||
stripeSubscriptionStart?: Date,
|
||||
stripeSubscriptionEnd?: Date
|
||||
): Promise<void> {
|
||||
try {
|
||||
let start: Date
|
||||
let end: Date
|
||||
|
||||
if (stripeSubscriptionStart && stripeSubscriptionEnd) {
|
||||
// Use Stripe subscription dates for perfect alignment
|
||||
start = stripeSubscriptionStart
|
||||
end = stripeSubscriptionEnd
|
||||
logger.info('Using Stripe subscription dates for billing period', {
|
||||
userId,
|
||||
stripeStart: stripeSubscriptionStart,
|
||||
stripeEnd: stripeSubscriptionEnd,
|
||||
})
|
||||
} else {
|
||||
// Fallback: Get user's subscription to determine billing period
|
||||
const subscriptionData = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
const billingPeriod = calculateBillingPeriod(
|
||||
subscriptionData[0]?.periodStart || undefined,
|
||||
subscriptionData[0]?.periodEnd || undefined
|
||||
)
|
||||
start = billingPeriod.start
|
||||
end = billingPeriod.end
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentPeriodCost: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Billing period initialized for user', {
|
||||
userId,
|
||||
billingPeriodStart: start,
|
||||
billingPeriodEnd: end,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize billing period', { userId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset billing period for a user (archive current usage and start new period)
|
||||
* Now properly calculates next period based on subscription billing cycle
|
||||
*/
|
||||
export async function resetUserBillingPeriod(userId: string): Promise<void> {
|
||||
try {
|
||||
// Get current period data and subscription info before reset
|
||||
const [currentStats, userSubscription] = await Promise.all([
|
||||
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
|
||||
.limit(1),
|
||||
])
|
||||
|
||||
if (currentStats.length === 0) {
|
||||
logger.warn('No user stats found for billing period reset', { userId })
|
||||
return
|
||||
}
|
||||
|
||||
const stats = currentStats[0]
|
||||
const currentPeriodCost = stats.currentPeriodCost || '0'
|
||||
|
||||
// Calculate next billing period based on subscription or current period end
|
||||
let newPeriodStart: Date
|
||||
let newPeriodEnd: Date
|
||||
|
||||
if (userSubscription.length > 0 && userSubscription[0].periodEnd) {
|
||||
// Use subscription-based period calculation
|
||||
const nextPeriod = calculateNextBillingPeriod(userSubscription[0].periodEnd)
|
||||
newPeriodStart = nextPeriod.start
|
||||
newPeriodEnd = nextPeriod.end
|
||||
} else if (stats.billingPeriodEnd) {
|
||||
// Use current billing period end to calculate next period
|
||||
const nextPeriod = calculateNextBillingPeriod(stats.billingPeriodEnd)
|
||||
newPeriodStart = nextPeriod.start
|
||||
newPeriodEnd = nextPeriod.end
|
||||
} else {
|
||||
// Fallback to subscription start date or default calculation
|
||||
const subscriptionStart = userSubscription[0]?.periodStart
|
||||
const billingPeriod = calculateBillingPeriod(subscriptionStart || undefined)
|
||||
newPeriodStart = billingPeriod.start
|
||||
newPeriodEnd = billingPeriod.end
|
||||
}
|
||||
|
||||
// Archive current period cost and reset for new period (no longer updating period dates in user_stats)
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
lastPeriodCost: currentPeriodCost,
|
||||
currentPeriodCost: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Reset billing period for user', {
|
||||
userId,
|
||||
archivedAmount: currentPeriodCost,
|
||||
newPeriodStart,
|
||||
newPeriodEnd,
|
||||
basedOnSubscription: !!userSubscription[0]?.periodEnd,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset user billing period', { userId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset billing period for all members of an organization
|
||||
*/
|
||||
export async function resetOrganizationBillingPeriod(organizationId: string): Promise<void> {
|
||||
try {
|
||||
// Get all organization members
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
if (members.length === 0) {
|
||||
logger.info('No members found for organization billing reset', { organizationId })
|
||||
return
|
||||
}
|
||||
|
||||
// Reset billing period for each member in parallel
|
||||
const memberUserIds = members.map((m) => m.userId)
|
||||
|
||||
await Promise.all(
|
||||
memberUserIds.map(async (userId) => {
|
||||
try {
|
||||
await resetUserBillingPeriod(userId)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset billing period for organization member', {
|
||||
organizationId,
|
||||
userId,
|
||||
error,
|
||||
})
|
||||
// Don't throw - continue processing other members
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
logger.info('Reset billing period for organization', {
|
||||
organizationId,
|
||||
memberCount: members.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset organization billing period', { organizationId, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getPlanPricing,
|
||||
getUsersAndOrganizationsForOverageBilling,
|
||||
} from '@/lib/billing/core/billing'
|
||||
import {
|
||||
calculateBillingPeriod,
|
||||
calculateNextBillingPeriod,
|
||||
} from '@/lib/billing/core/billing-periods'
|
||||
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/usage', () => ({
|
||||
getUserUsageData: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/stripe-client', () => ({
|
||||
getStripeClient: vi.fn().mockReturnValue(null),
|
||||
requireStripeClient: vi.fn().mockImplementation(() => {
|
||||
throw new Error(
|
||||
'Stripe client is not available. Set STRIPE_SECRET_KEY in your environment variables.'
|
||||
)
|
||||
}),
|
||||
hasValidStripeCredentials: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
describe('Billing Core Functions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.setSystemTime(new Date('2024-07-06T10:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('calculateBillingPeriod', () => {
|
||||
it.concurrent('calculates billing period from subscription dates correctly', () => {
|
||||
vi.setSystemTime(new Date('2024-07-06T10:00:00Z'))
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
const subscriptionEnd = new Date('2024-08-15T00:00:00Z')
|
||||
const result = calculateBillingPeriod(subscriptionStart, subscriptionEnd)
|
||||
|
||||
// Should return the current subscription period since we're within it
|
||||
expect(result.start).toEqual(subscriptionStart)
|
||||
expect(result.end).toEqual(subscriptionEnd)
|
||||
expect(result.start.getUTCDate()).toBe(15) // Should preserve day from subscription
|
||||
expect(result.end.getUTCDate()).toBe(15)
|
||||
})
|
||||
|
||||
it.concurrent('calculates next period when current subscription period has ended', () => {
|
||||
vi.setSystemTime(new Date('2024-08-20T10:00:00Z')) // After subscription end
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
const subscriptionEnd = new Date('2024-08-15T00:00:00Z') // Already ended
|
||||
const result = calculateBillingPeriod(subscriptionStart, subscriptionEnd)
|
||||
|
||||
// Should calculate next period starting from subscription end
|
||||
expect(result.start).toEqual(subscriptionEnd)
|
||||
expect(result.end.getUTCFullYear()).toBe(2024)
|
||||
expect(result.end.getUTCMonth()).toBe(8) // September (0-indexed)
|
||||
expect(result.end.getUTCDate()).toBe(15) // Should preserve day
|
||||
})
|
||||
|
||||
it.concurrent('returns current month when no subscription date provided', () => {
|
||||
vi.setSystemTime(new Date('2024-07-15T10:00:00Z'))
|
||||
const result = calculateBillingPeriod()
|
||||
|
||||
// Should return current calendar month (1st to last day of current month)
|
||||
expect(result.start.getUTCFullYear()).toBe(2024)
|
||||
expect(result.start.getUTCMonth()).toBe(6) // July (0-indexed)
|
||||
expect(result.start.getUTCDate()).toBe(1) // Should start on 1st of month
|
||||
expect(result.end.getUTCFullYear()).toBe(2024)
|
||||
expect(result.end.getUTCMonth()).toBe(6) // July (0-indexed) - ends on last day of current month
|
||||
expect(result.end.getUTCDate()).toBe(31) // Should end on last day of July
|
||||
expect(result.end.getUTCHours()).toBe(23) // Should end at 23:59:59.999
|
||||
expect(result.end.getUTCMinutes()).toBe(59)
|
||||
expect(result.end.getUTCSeconds()).toBe(59)
|
||||
})
|
||||
|
||||
it.concurrent('handles subscription anniversary date correctly', () => {
|
||||
vi.setSystemTime(new Date('2024-07-06T10:00:00Z'))
|
||||
const subscriptionStart = new Date('2024-01-15T00:00:00Z')
|
||||
const subscriptionEnd = new Date('2024-07-15T00:00:00Z')
|
||||
const result = calculateBillingPeriod(subscriptionStart, subscriptionEnd)
|
||||
|
||||
// Should maintain the 15th as billing day
|
||||
expect(result.start.getUTCDate()).toBe(15)
|
||||
expect(result.end.getUTCDate()).toBe(15)
|
||||
|
||||
// Current period should contain the current date (July 6)
|
||||
const currentDate = new Date('2024-07-06T10:00:00Z')
|
||||
expect(currentDate.getTime()).toBeGreaterThanOrEqual(result.start.getTime())
|
||||
expect(currentDate.getTime()).toBeLessThan(result.end.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateNextBillingPeriod', () => {
|
||||
it.concurrent('calculates next period correctly', () => {
|
||||
const currentPeriodEnd = new Date('2024-07-15T23:59:59Z')
|
||||
const result = calculateNextBillingPeriod(currentPeriodEnd)
|
||||
|
||||
expect(result.start.getUTCDate()).toBe(15)
|
||||
expect(result.start.getUTCMonth()).toBe(6) // July (0-indexed)
|
||||
expect(result.end.getUTCDate()).toBe(15)
|
||||
expect(result.end.getUTCMonth()).toBe(7) // August (0-indexed)
|
||||
})
|
||||
|
||||
it.concurrent('handles month boundary correctly', () => {
|
||||
const currentPeriodEnd = new Date('2024-01-31T23:59:59Z')
|
||||
const result = calculateNextBillingPeriod(currentPeriodEnd)
|
||||
|
||||
expect(result.start.getUTCMonth()).toBe(0) // January
|
||||
expect(result.end.getUTCMonth()).toBeGreaterThanOrEqual(1) // February or later due to month overflow
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPlanPricing', () => {
|
||||
it.concurrent('returns correct pricing for free plan', () => {
|
||||
const result = getPlanPricing('free')
|
||||
expect(result).toEqual({ basePrice: 0, minimum: 0 })
|
||||
})
|
||||
|
||||
it.concurrent('returns correct pricing for pro plan', () => {
|
||||
const result = getPlanPricing('pro')
|
||||
expect(result).toEqual({ basePrice: 20, minimum: 20 })
|
||||
})
|
||||
|
||||
it.concurrent('returns correct pricing for team plan', () => {
|
||||
const result = getPlanPricing('team')
|
||||
expect(result).toEqual({ basePrice: 40, minimum: 40 })
|
||||
})
|
||||
|
||||
it.concurrent('returns correct pricing for enterprise plan with metadata', () => {
|
||||
const subscription = {
|
||||
metadata: { perSeatAllowance: 150 },
|
||||
}
|
||||
const result = getPlanPricing('enterprise', subscription)
|
||||
expect(result).toEqual({ basePrice: 150, minimum: 150 })
|
||||
})
|
||||
|
||||
it.concurrent('handles invalid perSeatAllowance values - negative number', () => {
|
||||
const subscription = {
|
||||
metadata: { perSeatAllowance: -50 },
|
||||
}
|
||||
const result = getPlanPricing('enterprise', subscription)
|
||||
// Should fall back to default enterprise pricing
|
||||
expect(result).toEqual({ basePrice: 100, minimum: 100 })
|
||||
})
|
||||
|
||||
it.concurrent('handles invalid perSeatAllowance values - zero', () => {
|
||||
const subscription = {
|
||||
metadata: { perSeatAllowance: 0 },
|
||||
}
|
||||
const result = getPlanPricing('enterprise', subscription)
|
||||
// Should fall back to default enterprise pricing
|
||||
expect(result).toEqual({ basePrice: 100, minimum: 100 })
|
||||
})
|
||||
|
||||
it.concurrent('handles invalid perSeatAllowance values - non-numeric string', () => {
|
||||
const subscription = {
|
||||
metadata: { perSeatAllowance: 'invalid' },
|
||||
}
|
||||
const result = getPlanPricing('enterprise', subscription)
|
||||
// Should fall back to default enterprise pricing
|
||||
expect(result).toEqual({ basePrice: 100, minimum: 100 })
|
||||
})
|
||||
|
||||
it.concurrent('handles invalid perSeatAllowance values - null', () => {
|
||||
const subscription = {
|
||||
metadata: { perSeatAllowance: null },
|
||||
}
|
||||
const result = getPlanPricing('enterprise', subscription)
|
||||
// Should fall back to default enterprise pricing
|
||||
expect(result).toEqual({ basePrice: 100, minimum: 100 })
|
||||
})
|
||||
|
||||
it.concurrent('returns default enterprise pricing when metadata missing', () => {
|
||||
const result = getPlanPricing('enterprise')
|
||||
expect(result).toEqual({ basePrice: 100, minimum: 100 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsersAndOrganizationsForOverageBilling', () => {
|
||||
it.concurrent('returns empty arrays when no subscriptions due', async () => {
|
||||
const result = await getUsersAndOrganizationsForOverageBilling()
|
||||
|
||||
expect(result).toHaveProperty('users')
|
||||
expect(result).toHaveProperty('organizations')
|
||||
expect(Array.isArray(result.users)).toBe(true)
|
||||
expect(Array.isArray(result.organizations)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('filters by current date correctly', async () => {
|
||||
vi.setSystemTime(new Date('2024-07-15T10:00:00Z'))
|
||||
|
||||
const result = await getUsersAndOrganizationsForOverageBilling()
|
||||
|
||||
// Should only return entities whose billing period ends on July 15th
|
||||
expect(result.users).toEqual([])
|
||||
expect(result.organizations).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stripe client integration', () => {
|
||||
it.concurrent('does not fail when Stripe credentials are not available', async () => {
|
||||
const result = await getUsersAndOrganizationsForOverageBilling()
|
||||
|
||||
expect(result).toHaveProperty('users')
|
||||
expect(result).toHaveProperty('organizations')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Date handling edge cases', () => {
|
||||
it.concurrent('handles month boundaries correctly', () => {
|
||||
// Test end of January (28/29 days) to February
|
||||
const janEnd = new Date('2024-01-31T00:00:00Z')
|
||||
const result = calculateNextBillingPeriod(janEnd)
|
||||
|
||||
expect(result.start.getUTCMonth()).toBe(0) // January
|
||||
expect(result.end.getUTCMonth()).toBeGreaterThanOrEqual(1) // February or later due to month overflow
|
||||
})
|
||||
|
||||
it.concurrent('handles leap year correctly', () => {
|
||||
const febEnd = new Date('2024-02-29T00:00:00Z')
|
||||
const result = calculateNextBillingPeriod(febEnd)
|
||||
|
||||
expect(result.start.getUTCFullYear()).toBe(2024)
|
||||
expect(result.start.getUTCMonth()).toBe(1)
|
||||
expect(result.start.getUTCDate()).toBe(29)
|
||||
expect(result.end.getUTCFullYear()).toBe(2024)
|
||||
expect(result.end.getUTCMonth()).toBe(2)
|
||||
expect(result.end.getUTCDate()).toBe(29)
|
||||
})
|
||||
|
||||
it.concurrent('handles year boundary correctly', () => {
|
||||
const decEnd = new Date('2024-12-15T00:00:00Z')
|
||||
const result = calculateNextBillingPeriod(decEnd)
|
||||
|
||||
expect(result.start.getUTCFullYear()).toBe(2024)
|
||||
expect(result.start.getUTCMonth()).toBe(11) // December
|
||||
expect(result.end.getUTCFullYear()).toBe(2025)
|
||||
expect(result.end.getUTCMonth()).toBe(0) // January
|
||||
})
|
||||
|
||||
it.concurrent('basic date calculations work', () => {
|
||||
const testDate = new Date('2024-07-15T00:00:00Z')
|
||||
const result = calculateNextBillingPeriod(testDate)
|
||||
|
||||
expect(result.start).toBeInstanceOf(Date)
|
||||
expect(result.end).toBeInstanceOf(Date)
|
||||
expect(result.end.getTime()).toBeGreaterThan(result.start.getTime())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,16 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import {
|
||||
resetOrganizationBillingPeriod,
|
||||
resetUserBillingPeriod,
|
||||
} from '@/lib/billing/core/billing-periods'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import {
|
||||
getEnterpriseTierLimitPerSeat,
|
||||
getFreeTierLimit,
|
||||
getProTierLimit,
|
||||
getTeamTierLimitPerSeat,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member, organization, subscription, user } from '@/db/schema'
|
||||
import { member, subscription, user } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('Billing')
|
||||
|
||||
@@ -31,13 +32,6 @@ export async function getOrganizationSubscription(organizationId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
interface BillingResult {
|
||||
success: boolean
|
||||
chargedAmount?: number
|
||||
invoiceId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* BILLING MODEL:
|
||||
* 1. User purchases $20 Pro plan → Gets charged $20 immediately via Stripe subscription
|
||||
@@ -53,271 +47,35 @@ export function getPlanPricing(
|
||||
plan: string,
|
||||
subscription?: any
|
||||
): {
|
||||
basePrice: number // What they pay upfront via Stripe subscription
|
||||
minimum: number // Minimum they're guaranteed to pay
|
||||
basePrice: number // What they pay upfront via Stripe subscription (per seat for team/enterprise)
|
||||
} {
|
||||
switch (plan) {
|
||||
case 'free':
|
||||
return { basePrice: 0, minimum: 0 } // Free plan has no charges
|
||||
return { basePrice: 0 } // Free plan has no charges
|
||||
case 'pro':
|
||||
return { basePrice: 20, minimum: 20 } // $20/month subscription
|
||||
return { basePrice: getProTierLimit() }
|
||||
case 'team':
|
||||
return { basePrice: 40, minimum: 40 } // $40/seat/month subscription
|
||||
return { basePrice: getTeamTierLimitPerSeat() }
|
||||
case 'enterprise':
|
||||
// Get per-seat pricing from metadata
|
||||
// Enterprise uses per-seat pricing like Team plans
|
||||
// Custom per-seat price can be set in metadata
|
||||
if (subscription?.metadata) {
|
||||
const metadata =
|
||||
const metadata: EnterpriseSubscriptionMetadata =
|
||||
typeof subscription.metadata === 'string'
|
||||
? JSON.parse(subscription.metadata)
|
||||
: subscription.metadata
|
||||
|
||||
// Validate perSeatAllowance is a positive number
|
||||
const perSeatAllowance = metadata.perSeatAllowance
|
||||
const perSeatPrice =
|
||||
typeof perSeatAllowance === 'number' && perSeatAllowance > 0 ? perSeatAllowance : 100 // Fall back to default for invalid values
|
||||
|
||||
return { basePrice: perSeatPrice, minimum: perSeatPrice }
|
||||
}
|
||||
return { basePrice: 100, minimum: 100 } // Default enterprise pricing
|
||||
default:
|
||||
return { basePrice: 0, minimum: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Stripe customer ID for a user or organization
|
||||
*/
|
||||
async function getStripeCustomerId(referenceId: string): Promise<string | null> {
|
||||
try {
|
||||
// First check if it's a user
|
||||
const userRecord = await db
|
||||
.select({ stripeCustomerId: user.stripeCustomerId })
|
||||
.from(user)
|
||||
.where(eq(user.id, referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (userRecord.length > 0 && userRecord[0].stripeCustomerId) {
|
||||
return userRecord[0].stripeCustomerId
|
||||
}
|
||||
|
||||
// Check if it's an organization
|
||||
const orgRecord = await db
|
||||
.select({ metadata: organization.metadata })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (orgRecord.length > 0) {
|
||||
// First, check if organization has its own Stripe customer (legacy support)
|
||||
if (orgRecord[0].metadata) {
|
||||
const metadata =
|
||||
typeof orgRecord[0].metadata === 'string'
|
||||
? JSON.parse(orgRecord[0].metadata)
|
||||
: orgRecord[0].metadata
|
||||
|
||||
if (metadata?.stripeCustomerId) {
|
||||
return metadata.stripeCustomerId
|
||||
const perSeatPrice = metadata.perSeatPrice
|
||||
? Number.parseFloat(String(metadata.perSeatPrice))
|
||||
: undefined
|
||||
if (perSeatPrice && perSeatPrice > 0 && !Number.isNaN(perSeatPrice)) {
|
||||
return { basePrice: perSeatPrice }
|
||||
}
|
||||
}
|
||||
|
||||
// If organization has no Stripe customer, use the owner's customer
|
||||
// This is our new pattern: subscriptions stay with user, referenceId = orgId
|
||||
const ownerRecord = await db
|
||||
.select({
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
userId: user.id,
|
||||
})
|
||||
.from(user)
|
||||
.innerJoin(member, eq(member.userId, user.id))
|
||||
.where(and(eq(member.organizationId, referenceId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (ownerRecord.length > 0 && ownerRecord[0].stripeCustomerId) {
|
||||
logger.debug('Using organization owner Stripe customer for billing', {
|
||||
organizationId: referenceId,
|
||||
ownerId: ownerRecord[0].userId,
|
||||
stripeCustomerId: ownerRecord[0].stripeCustomerId,
|
||||
})
|
||||
return ownerRecord[0].stripeCustomerId
|
||||
}
|
||||
|
||||
logger.warn('No Stripe customer found for organization or its owner', {
|
||||
organizationId: referenceId,
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Failed to get Stripe customer ID', { referenceId, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe invoice for overage billing only
|
||||
*/
|
||||
export async function createOverageBillingInvoice(
|
||||
customerId: string,
|
||||
overageAmount: number,
|
||||
description: string,
|
||||
metadata: Record<string, string> = {}
|
||||
): Promise<BillingResult> {
|
||||
try {
|
||||
if (overageAmount <= 0) {
|
||||
logger.info('No overage to bill', { customerId, overageAmount })
|
||||
return { success: true, chargedAmount: 0 }
|
||||
}
|
||||
|
||||
const stripeClient = requireStripeClient()
|
||||
|
||||
// Check for existing overage invoice for this billing period
|
||||
const billingPeriod = metadata.billingPeriod || new Date().toISOString().slice(0, 7)
|
||||
|
||||
// Get the start of the billing period month for filtering
|
||||
const periodStart = new Date(`${billingPeriod}-01`)
|
||||
const periodStartTimestamp = Math.floor(periodStart.getTime() / 1000)
|
||||
|
||||
// Look for invoices created in the last 35 days to cover month boundaries
|
||||
const recentInvoices = await stripeClient.invoices.list({
|
||||
customer: customerId,
|
||||
created: {
|
||||
gte: periodStartTimestamp,
|
||||
},
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
// Check if we already have an overage invoice for this period
|
||||
const existingOverageInvoice = recentInvoices.data.find(
|
||||
(invoice) =>
|
||||
invoice.metadata?.type === 'overage_billing' &&
|
||||
invoice.metadata?.billingPeriod === billingPeriod &&
|
||||
invoice.status !== 'void' // Ignore voided invoices
|
||||
)
|
||||
|
||||
if (existingOverageInvoice) {
|
||||
logger.warn('Overage invoice already exists for this billing period', {
|
||||
customerId,
|
||||
billingPeriod,
|
||||
existingInvoiceId: existingOverageInvoice.id,
|
||||
existingInvoiceStatus: existingOverageInvoice.status,
|
||||
existingAmount: existingOverageInvoice.amount_due / 100,
|
||||
})
|
||||
|
||||
// Return success but with no charge to prevent duplicate billing
|
||||
return {
|
||||
success: true,
|
||||
chargedAmount: 0,
|
||||
invoiceId: existingOverageInvoice.id,
|
||||
}
|
||||
}
|
||||
|
||||
// Get customer to ensure they have an email set
|
||||
const customer = await stripeClient.customers.retrieve(customerId)
|
||||
if (!('email' in customer) || !customer.email) {
|
||||
logger.warn('Customer does not have an email set, Stripe will not send automatic emails', {
|
||||
customerId,
|
||||
})
|
||||
}
|
||||
|
||||
const invoiceItem = await stripeClient.invoiceItems.create({
|
||||
customer: customerId,
|
||||
amount: Math.round(overageAmount * 100), // Convert to cents
|
||||
currency: 'usd',
|
||||
description,
|
||||
metadata: {
|
||||
...metadata,
|
||||
type: 'overage_billing',
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('Created overage invoice item', {
|
||||
customerId,
|
||||
amount: overageAmount,
|
||||
invoiceItemId: invoiceItem.id,
|
||||
})
|
||||
|
||||
// Create invoice that will include the invoice item
|
||||
const invoice = await stripeClient.invoices.create({
|
||||
customer: customerId,
|
||||
auto_advance: true, // Automatically finalize
|
||||
collection_method: 'charge_automatically', // Charge immediately
|
||||
metadata: {
|
||||
...metadata,
|
||||
type: 'overage_billing',
|
||||
},
|
||||
description,
|
||||
pending_invoice_items_behavior: 'include', // Explicitly include pending items
|
||||
payment_settings: {
|
||||
payment_method_types: ['card'], // Accept card payments
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('Created overage invoice', {
|
||||
customerId,
|
||||
invoiceId: invoice.id,
|
||||
amount: overageAmount,
|
||||
status: invoice.status,
|
||||
})
|
||||
|
||||
// If invoice is still draft (shouldn't happen with auto_advance), finalize it
|
||||
let finalInvoice = invoice
|
||||
if (invoice.status === 'draft') {
|
||||
logger.warn('Invoice created as draft, manually finalizing', { invoiceId: invoice.id })
|
||||
finalInvoice = await stripeClient.invoices.finalizeInvoice(invoice.id)
|
||||
logger.info('Manually finalized invoice', {
|
||||
invoiceId: finalInvoice.id,
|
||||
status: finalInvoice.status,
|
||||
})
|
||||
}
|
||||
|
||||
// If invoice is open (finalized but not paid), attempt to pay it
|
||||
if (finalInvoice.status === 'open') {
|
||||
try {
|
||||
logger.info('Attempting to pay open invoice', { invoiceId: finalInvoice.id })
|
||||
const paidInvoice = await stripeClient.invoices.pay(finalInvoice.id)
|
||||
logger.info('Successfully paid invoice', {
|
||||
invoiceId: paidInvoice.id,
|
||||
status: paidInvoice.status,
|
||||
amountPaid: paidInvoice.amount_paid / 100,
|
||||
})
|
||||
finalInvoice = paidInvoice
|
||||
} catch (paymentError) {
|
||||
logger.error('Failed to automatically pay invoice', {
|
||||
invoiceId: finalInvoice.id,
|
||||
error: paymentError,
|
||||
})
|
||||
// Don't fail the whole operation if payment fails
|
||||
// Stripe will retry and send payment failure notifications
|
||||
}
|
||||
}
|
||||
|
||||
// Log final invoice status
|
||||
logger.info('Invoice processing complete', {
|
||||
customerId,
|
||||
invoiceId: finalInvoice.id,
|
||||
chargedAmount: overageAmount,
|
||||
description,
|
||||
status: finalInvoice.status,
|
||||
paymentAttempted: finalInvoice.status === 'paid' || finalInvoice.attempted,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chargedAmount: overageAmount,
|
||||
invoiceId: finalInvoice.id,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create overage billing invoice', {
|
||||
customerId,
|
||||
overageAmount,
|
||||
description,
|
||||
error,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
}
|
||||
// Default enterprise per-seat pricing
|
||||
return { basePrice: getEnterpriseTierLimitPerSeat() }
|
||||
default:
|
||||
return { basePrice: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,374 +121,6 @@ export async function calculateUserOverage(userId: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process overage billing for an individual user
|
||||
*/
|
||||
export async function processUserOverageBilling(userId: string): Promise<BillingResult> {
|
||||
try {
|
||||
const overageInfo = await calculateUserOverage(userId)
|
||||
|
||||
if (!overageInfo) {
|
||||
return { success: false, error: 'Failed to calculate overage information' }
|
||||
}
|
||||
|
||||
// Skip billing for free plan users
|
||||
if (overageInfo.plan === 'free') {
|
||||
logger.info('Skipping overage billing for free plan user', { userId })
|
||||
return { success: true, chargedAmount: 0 }
|
||||
}
|
||||
|
||||
// Skip if no overage
|
||||
if (overageInfo.overageAmount <= 0) {
|
||||
logger.info('No overage to bill for user', {
|
||||
userId,
|
||||
basePrice: overageInfo.basePrice,
|
||||
actualUsage: overageInfo.actualUsage,
|
||||
})
|
||||
|
||||
// Still reset billing period even if no overage
|
||||
try {
|
||||
await resetUserBillingPeriod(userId)
|
||||
} catch (resetError) {
|
||||
logger.error('Failed to reset billing period', { userId, error: resetError })
|
||||
}
|
||||
|
||||
return { success: true, chargedAmount: 0 }
|
||||
}
|
||||
|
||||
// Get Stripe customer ID
|
||||
const stripeCustomerId = await getStripeCustomerId(userId)
|
||||
if (!stripeCustomerId) {
|
||||
logger.error('No Stripe customer ID found for user', { userId })
|
||||
return { success: false, error: 'No Stripe customer ID found' }
|
||||
}
|
||||
|
||||
// Get user email to ensure Stripe customer has it set
|
||||
const userRecord = await db
|
||||
.select({ email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (userRecord[0]?.email) {
|
||||
// Update Stripe customer with email if needed
|
||||
const stripeClient = requireStripeClient()
|
||||
try {
|
||||
await stripeClient.customers.update(stripeCustomerId, {
|
||||
email: userRecord[0].email,
|
||||
})
|
||||
logger.info('Updated Stripe customer with email', {
|
||||
userId,
|
||||
stripeCustomerId,
|
||||
email: userRecord[0].email,
|
||||
})
|
||||
} catch (updateError) {
|
||||
logger.warn('Failed to update Stripe customer email', {
|
||||
userId,
|
||||
stripeCustomerId,
|
||||
error: updateError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const description = `Usage overage for ${overageInfo.plan} plan - $${overageInfo.overageAmount.toFixed(2)} above $${overageInfo.basePrice} base`
|
||||
const metadata = {
|
||||
userId,
|
||||
plan: overageInfo.plan,
|
||||
basePrice: overageInfo.basePrice.toString(),
|
||||
actualUsage: overageInfo.actualUsage.toString(),
|
||||
overageAmount: overageInfo.overageAmount.toString(),
|
||||
billingPeriod: new Date().toISOString().slice(0, 7), // YYYY-MM format
|
||||
}
|
||||
|
||||
const result = await createOverageBillingInvoice(
|
||||
stripeCustomerId,
|
||||
overageInfo.overageAmount,
|
||||
description,
|
||||
metadata
|
||||
)
|
||||
|
||||
// If billing was successful, reset the user's billing period
|
||||
if (result.success) {
|
||||
try {
|
||||
await resetUserBillingPeriod(userId)
|
||||
logger.info('Successfully reset billing period after charging user overage', { userId })
|
||||
} catch (resetError) {
|
||||
logger.error('Failed to reset billing period after successful overage charge', {
|
||||
userId,
|
||||
error: resetError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Failed to process user overage billing', { userId, error })
|
||||
return { success: false, error: 'Failed to process overage billing' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process overage billing for an organization (team/enterprise plans)
|
||||
*/
|
||||
export async function processOrganizationOverageBilling(
|
||||
organizationId: string
|
||||
): Promise<BillingResult> {
|
||||
try {
|
||||
// Get organization subscription directly (referenceId = organizationId)
|
||||
const subscription = await getOrganizationSubscription(organizationId)
|
||||
|
||||
if (!subscription || !['team', 'enterprise'].includes(subscription.plan)) {
|
||||
logger.warn('No team/enterprise subscription found for organization', { organizationId })
|
||||
return { success: false, error: 'No valid subscription found' }
|
||||
}
|
||||
|
||||
// Get organization's Stripe customer ID
|
||||
const stripeCustomerId = await getStripeCustomerId(organizationId)
|
||||
if (!stripeCustomerId) {
|
||||
logger.error('No Stripe customer ID found for organization', { organizationId })
|
||||
return { success: false, error: 'No Stripe customer ID found' }
|
||||
}
|
||||
|
||||
// Get organization owner's email for billing
|
||||
const orgOwner = await db
|
||||
.select({
|
||||
userId: member.userId,
|
||||
userEmail: user.email,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (orgOwner[0]?.userEmail) {
|
||||
// Update Stripe customer with organization owner's email
|
||||
const stripeClient = requireStripeClient()
|
||||
try {
|
||||
await stripeClient.customers.update(stripeCustomerId, {
|
||||
email: orgOwner[0].userEmail,
|
||||
})
|
||||
logger.info('Updated Stripe customer with organization owner email', {
|
||||
organizationId,
|
||||
stripeCustomerId,
|
||||
email: orgOwner[0].userEmail,
|
||||
})
|
||||
} catch (updateError) {
|
||||
logger.warn('Failed to update Stripe customer email for organization', {
|
||||
organizationId,
|
||||
stripeCustomerId,
|
||||
error: updateError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get all organization members
|
||||
const members = await db
|
||||
.select({
|
||||
userId: member.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
if (members.length === 0) {
|
||||
logger.info('No members found for organization overage billing', { organizationId })
|
||||
return { success: true, chargedAmount: 0 }
|
||||
}
|
||||
|
||||
// Calculate total team usage across all members
|
||||
const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan, subscription)
|
||||
const licensedSeats = subscription.seats || 1
|
||||
const baseSubscriptionAmount = licensedSeats * basePricePerSeat // What Stripe already charged
|
||||
|
||||
let totalTeamUsage = 0
|
||||
const memberUsageDetails = []
|
||||
|
||||
for (const memberInfo of members) {
|
||||
const usageData = await getUserUsageData(memberInfo.userId)
|
||||
totalTeamUsage += usageData.currentUsage
|
||||
|
||||
memberUsageDetails.push({
|
||||
userId: memberInfo.userId,
|
||||
name: memberInfo.userName,
|
||||
email: memberInfo.userEmail,
|
||||
usage: usageData.currentUsage,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate team-level overage: total usage beyond what was already paid to Stripe
|
||||
const totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount)
|
||||
|
||||
// Skip if no overage across the organization
|
||||
if (totalOverage <= 0) {
|
||||
logger.info('No overage to bill for organization', {
|
||||
organizationId,
|
||||
licensedSeats,
|
||||
memberCount: members.length,
|
||||
totalTeamUsage,
|
||||
baseSubscriptionAmount,
|
||||
})
|
||||
|
||||
// Still reset billing period for all members
|
||||
try {
|
||||
await resetOrganizationBillingPeriod(organizationId)
|
||||
} catch (resetError) {
|
||||
logger.error('Failed to reset organization billing period', {
|
||||
organizationId,
|
||||
error: resetError,
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true, chargedAmount: 0 }
|
||||
}
|
||||
|
||||
// Create consolidated overage invoice for the organization
|
||||
const description = `Team usage overage for ${subscription.plan} plan - ${licensedSeats} licensed seats, $${totalTeamUsage.toFixed(2)} total usage, $${totalOverage.toFixed(2)} overage`
|
||||
const metadata = {
|
||||
organizationId,
|
||||
plan: subscription.plan,
|
||||
licensedSeats: licensedSeats.toString(),
|
||||
memberCount: members.length.toString(),
|
||||
basePricePerSeat: basePricePerSeat.toString(),
|
||||
baseSubscriptionAmount: baseSubscriptionAmount.toString(),
|
||||
totalTeamUsage: totalTeamUsage.toString(),
|
||||
totalOverage: totalOverage.toString(),
|
||||
billingPeriod: new Date().toISOString().slice(0, 7), // YYYY-MM format
|
||||
memberDetails: JSON.stringify(memberUsageDetails),
|
||||
}
|
||||
|
||||
const result = await createOverageBillingInvoice(
|
||||
stripeCustomerId,
|
||||
totalOverage,
|
||||
description,
|
||||
metadata
|
||||
)
|
||||
|
||||
// If billing was successful, reset billing period for all organization members
|
||||
if (result.success) {
|
||||
try {
|
||||
await resetOrganizationBillingPeriod(organizationId)
|
||||
logger.info('Successfully reset billing period for organization after overage billing', {
|
||||
organizationId,
|
||||
memberCount: members.length,
|
||||
})
|
||||
} catch (resetError) {
|
||||
logger.error(
|
||||
'Failed to reset organization billing period after successful overage charge',
|
||||
{
|
||||
organizationId,
|
||||
error: resetError,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Processed organization overage billing', {
|
||||
organizationId,
|
||||
memberCount: members.length,
|
||||
totalOverage,
|
||||
result,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error('Failed to process organization overage billing', { organizationId, error })
|
||||
return { success: false, error: 'Failed to process organization overage billing' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users and organizations whose billing periods end today
|
||||
*/
|
||||
export async function getUsersAndOrganizationsForOverageBilling(): Promise<{
|
||||
users: string[]
|
||||
organizations: string[]
|
||||
}> {
|
||||
try {
|
||||
const today = new Date()
|
||||
today.setUTCHours(0, 0, 0, 0) // Start of today
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1) // Start of tomorrow
|
||||
|
||||
logger.info('Checking for subscriptions with billing periods ending today', {
|
||||
today: today.toISOString(),
|
||||
tomorrow: tomorrow.toISOString(),
|
||||
})
|
||||
|
||||
// Get all active subscriptions (excluding free plans)
|
||||
const activeSubscriptions = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(eq(subscription.status, 'active'))
|
||||
|
||||
const users: string[] = []
|
||||
const organizations: string[] = []
|
||||
|
||||
for (const sub of activeSubscriptions) {
|
||||
if (sub.plan === 'free') {
|
||||
continue // Skip free plans
|
||||
}
|
||||
|
||||
// Check if subscription period ends today (range-based, inclusive of day)
|
||||
let shouldBillToday = false
|
||||
|
||||
if (sub.periodEnd) {
|
||||
const periodEnd = new Date(sub.periodEnd)
|
||||
const endsToday = periodEnd >= today && periodEnd < tomorrow
|
||||
|
||||
if (endsToday) {
|
||||
shouldBillToday = true
|
||||
logger.info('Subscription period ends today', {
|
||||
referenceId: sub.referenceId,
|
||||
plan: sub.plan,
|
||||
periodEnd: sub.periodEnd,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldBillToday) {
|
||||
// Check if referenceId is a user or organization
|
||||
const userExists = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.id, sub.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (userExists.length > 0) {
|
||||
// It's a user subscription (pro plan)
|
||||
users.push(sub.referenceId)
|
||||
} else {
|
||||
// Check if it's an organization
|
||||
const orgExists = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, sub.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (orgExists.length > 0) {
|
||||
// It's an organization subscription (team/enterprise)
|
||||
organizations.push(sub.referenceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Found entities for daily billing check', {
|
||||
userCount: users.length,
|
||||
organizationCount: organizations.length,
|
||||
users,
|
||||
organizations,
|
||||
})
|
||||
|
||||
return { users, organizations }
|
||||
} catch (error) {
|
||||
logger.error('Failed to get entities for daily billing check', { error })
|
||||
return { users: [], organizations: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive billing and subscription summary
|
||||
*/
|
||||
@@ -773,6 +163,7 @@ export async function getSimplifiedBillingSummary(
|
||||
}
|
||||
organizationData?: {
|
||||
seatCount: number
|
||||
memberCount: number
|
||||
totalBasePrice: number
|
||||
totalCurrentUsage: number
|
||||
totalOverage: number
|
||||
@@ -807,8 +198,9 @@ export async function getSimplifiedBillingSummary(
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan, subscription)
|
||||
// Use licensed seats from Stripe as source of truth
|
||||
const licensedSeats = subscription.seats || 1
|
||||
const totalBasePrice = basePricePerSeat * licensedSeats // Based on licensed seats, not member count
|
||||
const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription
|
||||
|
||||
let totalCurrentUsage = 0
|
||||
|
||||
@@ -869,6 +261,7 @@ export async function getSimplifiedBillingSummary(
|
||||
},
|
||||
organizationData: {
|
||||
seatCount: licensedSeats,
|
||||
memberCount: members.length,
|
||||
totalBasePrice,
|
||||
totalCurrentUsage,
|
||||
totalOverage,
|
||||
@@ -878,9 +271,26 @@ export async function getSimplifiedBillingSummary(
|
||||
|
||||
// Individual billing summary
|
||||
const { basePrice } = getPlanPricing(plan, subscription)
|
||||
const overageAmount = Math.max(0, usageData.currentUsage - basePrice)
|
||||
const percentUsed =
|
||||
usageData.limit > 0 ? Math.round((usageData.currentUsage / usageData.limit) * 100) : 0
|
||||
|
||||
// For team and enterprise plans, calculate total team usage instead of individual usage
|
||||
let currentUsage = usageData.currentUsage
|
||||
if ((isTeam || isEnterprise) && subscription?.referenceId) {
|
||||
// Get all team members and sum their usage
|
||||
const teamMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, subscription.referenceId))
|
||||
|
||||
let totalTeamUsage = 0
|
||||
for (const teamMember of teamMembers) {
|
||||
const memberUsageData = await getUserUsageData(teamMember.userId)
|
||||
totalTeamUsage += memberUsageData.currentUsage
|
||||
}
|
||||
currentUsage = totalTeamUsage
|
||||
}
|
||||
|
||||
const overageAmount = Math.max(0, currentUsage - basePrice)
|
||||
const percentUsed = usageData.limit > 0 ? Math.round((currentUsage / usageData.limit) * 100) : 0
|
||||
|
||||
// Calculate days remaining in billing period
|
||||
const daysRemaining = usageData.billingPeriodEnd
|
||||
@@ -894,13 +304,13 @@ export async function getSimplifiedBillingSummary(
|
||||
type: 'individual',
|
||||
plan,
|
||||
basePrice,
|
||||
currentUsage: usageData.currentUsage,
|
||||
currentUsage: currentUsage,
|
||||
overageAmount,
|
||||
totalProjected: basePrice + overageAmount,
|
||||
usageLimit: usageData.limit,
|
||||
percentUsed,
|
||||
isWarning: percentUsed >= 80 && percentUsed < 100,
|
||||
isExceeded: usageData.currentUsage >= usageData.limit,
|
||||
isExceeded: currentUsage >= usageData.limit,
|
||||
daysRemaining,
|
||||
// Subscription details
|
||||
isPaid,
|
||||
@@ -914,11 +324,11 @@ export async function getSimplifiedBillingSummary(
|
||||
periodEnd: subscription?.periodEnd || null,
|
||||
// Usage details
|
||||
usage: {
|
||||
current: usageData.currentUsage,
|
||||
current: currentUsage,
|
||||
limit: usageData.limit,
|
||||
percentUsed,
|
||||
isWarning: percentUsed >= 80 && percentUsed < 100,
|
||||
isExceeded: usageData.currentUsage >= usageData.limit,
|
||||
isExceeded: currentUsage >= usageData.limit,
|
||||
billingPeriodStart: usageData.billingPeriodStart,
|
||||
billingPeriodEnd: usageData.billingPeriodEnd,
|
||||
lastPeriodCost: usageData.lastPeriodCost,
|
||||
@@ -942,7 +352,7 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
|
||||
currentUsage: 0,
|
||||
overageAmount: 0,
|
||||
totalProjected: 0,
|
||||
usageLimit: DEFAULT_FREE_CREDITS,
|
||||
usageLimit: getFreeTierLimit(),
|
||||
percentUsed: 0,
|
||||
isWarning: false,
|
||||
isExceeded: false,
|
||||
@@ -960,7 +370,7 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
|
||||
// Usage details
|
||||
usage: {
|
||||
current: 0,
|
||||
limit: DEFAULT_FREE_CREDITS,
|
||||
limit: getFreeTierLimit(),
|
||||
percentUsed: 0,
|
||||
isWarning: false,
|
||||
isExceeded: false,
|
||||
@@ -971,111 +381,3 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process daily billing check for users and organizations with periods ending today
|
||||
*/
|
||||
export async function processDailyBillingCheck(): Promise<{
|
||||
success: boolean
|
||||
processedUsers: number
|
||||
processedOrganizations: number
|
||||
totalChargedAmount: number
|
||||
errors: string[]
|
||||
}> {
|
||||
try {
|
||||
logger.info('Starting daily billing check process')
|
||||
|
||||
const { users, organizations } = await getUsersAndOrganizationsForOverageBilling()
|
||||
|
||||
let processedUsers = 0
|
||||
let processedOrganizations = 0
|
||||
let totalChargedAmount = 0
|
||||
const errors: string[] = []
|
||||
|
||||
// Process individual users (pro plans)
|
||||
for (const userId of users) {
|
||||
try {
|
||||
const result = await processUserOverageBilling(userId)
|
||||
if (result.success) {
|
||||
processedUsers++
|
||||
totalChargedAmount += result.chargedAmount || 0
|
||||
logger.info('Successfully processed user overage billing', {
|
||||
userId,
|
||||
chargedAmount: result.chargedAmount,
|
||||
})
|
||||
} else {
|
||||
errors.push(`User ${userId}: ${result.error}`)
|
||||
logger.error('Failed to process user overage billing', { userId, error: result.error })
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `User ${userId}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
errors.push(errorMsg)
|
||||
logger.error('Exception during user overage billing', { userId, error })
|
||||
}
|
||||
}
|
||||
|
||||
// Process organizations (team/enterprise plans)
|
||||
for (const organizationId of organizations) {
|
||||
try {
|
||||
const result = await processOrganizationOverageBilling(organizationId)
|
||||
if (result.success) {
|
||||
processedOrganizations++
|
||||
totalChargedAmount += result.chargedAmount || 0
|
||||
logger.info('Successfully processed organization overage billing', {
|
||||
organizationId,
|
||||
chargedAmount: result.chargedAmount,
|
||||
})
|
||||
} else {
|
||||
errors.push(`Organization ${organizationId}: ${result.error}`)
|
||||
logger.error('Failed to process organization overage billing', {
|
||||
organizationId,
|
||||
error: result.error,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Organization ${organizationId}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
errors.push(errorMsg)
|
||||
logger.error('Exception during organization overage billing', { organizationId, error })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Completed daily billing check process', {
|
||||
processedUsers,
|
||||
processedOrganizations,
|
||||
totalChargedAmount,
|
||||
errorCount: errors.length,
|
||||
})
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
processedUsers,
|
||||
processedOrganizations,
|
||||
totalChargedAmount,
|
||||
errors,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Fatal error during daily billing check process', { error })
|
||||
return {
|
||||
success: false,
|
||||
processedUsers: 0,
|
||||
processedOrganizations: 0,
|
||||
totalChargedAmount: 0,
|
||||
errors: [error instanceof Error ? error.message : 'Fatal daily billing check process error'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy function for backward compatibility - now redirects to daily billing check
|
||||
* @deprecated Use processDailyBillingCheck instead
|
||||
*/
|
||||
export async function processMonthlyOverageBilling(): Promise<{
|
||||
success: boolean
|
||||
processedUsers: number
|
||||
processedOrganizations: number
|
||||
totalChargedAmount: number
|
||||
errors: string[]
|
||||
}> {
|
||||
logger.warn('processMonthlyOverageBilling is deprecated, use processDailyBillingCheck instead')
|
||||
return processDailyBillingCheck()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import { getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { getFreeTierLimit } from '@/lib/billing/subscriptions/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member, organization, subscription, user, userStats } from '@/db/schema'
|
||||
@@ -26,6 +26,10 @@ async function getOrganizationSubscription(organizationId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function roundCurrency(value: number): number {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
interface OrganizationUsageData {
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
@@ -33,8 +37,10 @@ interface OrganizationUsageData {
|
||||
subscriptionStatus: string
|
||||
totalSeats: number
|
||||
usedSeats: number
|
||||
seatsCount: number
|
||||
totalCurrentUsage: number
|
||||
totalUsageLimit: number
|
||||
minimumBillingAmount: number
|
||||
averageUsagePerMember: number
|
||||
billingPeriodStart: Date | null
|
||||
billingPeriodEnd: Date | null
|
||||
@@ -104,7 +110,7 @@ export async function getOrganizationBillingData(
|
||||
// Process member data
|
||||
const members: MemberUsageData[] = membersWithUsage.map((memberRecord) => {
|
||||
const currentUsage = Number(memberRecord.currentPeriodCost || 0)
|
||||
const usageLimit = Number(memberRecord.currentUsageLimit || DEFAULT_FREE_CREDITS)
|
||||
const usageLimit = Number(memberRecord.currentUsageLimit || getFreeTierLimit())
|
||||
const percentUsed = usageLimit > 0 ? (currentUsage / usageLimit) * 100 : 0
|
||||
|
||||
return {
|
||||
@@ -126,26 +132,33 @@ export async function getOrganizationBillingData(
|
||||
|
||||
// Get per-seat pricing for the plan
|
||||
const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan, subscription)
|
||||
const licensedSeats = subscription.seats || members.length
|
||||
|
||||
// Use Stripe subscription seats as source of truth
|
||||
// Ensure we always have at least 1 seat (protect against 0 or falsy values)
|
||||
const licensedSeats = Math.max(subscription.seats || 1, 1)
|
||||
|
||||
// Validate seat capacity - warn if members exceed licensed seats
|
||||
if (subscription.seats && members.length > subscription.seats) {
|
||||
if (members.length > licensedSeats) {
|
||||
logger.warn('Organization has more members than licensed seats', {
|
||||
organizationId,
|
||||
licensedSeats: subscription.seats,
|
||||
licensedSeats,
|
||||
actualMembers: members.length,
|
||||
plan: subscription.plan,
|
||||
})
|
||||
}
|
||||
|
||||
// Billing is based on licensed seats, not actual member count
|
||||
// Billing is based on licensed seats from Stripe, not actual member count
|
||||
// This ensures organizations pay for their seat capacity regardless of utilization
|
||||
const seatsCount = licensedSeats
|
||||
const minimumBillingAmount = seatsCount * pricePerSeat
|
||||
const minimumBillingAmount = licensedSeats * pricePerSeat
|
||||
|
||||
// Total usage limit represents the minimum amount the team will be billed
|
||||
// This is based on licensed seats, not individual member limits (which are personal controls)
|
||||
const totalUsageLimit = minimumBillingAmount
|
||||
// Total usage limit: never below the minimum based on licensed seats
|
||||
const configuredLimit = organizationData.orgUsageLimit
|
||||
? Number.parseFloat(organizationData.orgUsageLimit)
|
||||
: null
|
||||
const totalUsageLimit =
|
||||
configuredLimit !== null
|
||||
? Math.max(configuredLimit, minimumBillingAmount)
|
||||
: minimumBillingAmount
|
||||
|
||||
const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0
|
||||
|
||||
@@ -155,14 +168,16 @@ export async function getOrganizationBillingData(
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
organizationName: organizationData.name,
|
||||
organizationName: organizationData.name || '',
|
||||
subscriptionPlan: subscription.plan,
|
||||
subscriptionStatus: subscription.status || 'active',
|
||||
totalSeats: subscription.seats || 1,
|
||||
subscriptionStatus: subscription.status || 'inactive',
|
||||
totalSeats: Math.max(subscription.seats || 1, 1),
|
||||
usedSeats: members.length,
|
||||
totalCurrentUsage: Math.round(totalCurrentUsage * 100) / 100,
|
||||
totalUsageLimit: Math.round(totalUsageLimit * 100) / 100,
|
||||
averageUsagePerMember: Math.round(averageUsagePerMember * 100) / 100,
|
||||
seatsCount: licensedSeats,
|
||||
totalCurrentUsage: roundCurrency(totalCurrentUsage),
|
||||
totalUsageLimit: roundCurrency(totalUsageLimit),
|
||||
minimumBillingAmount: roundCurrency(minimumBillingAmount),
|
||||
averageUsagePerMember: roundCurrency(averageUsagePerMember),
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
members: members.sort((a, b) => b.currentUsage - a.currentUsage), // Sort by usage desc
|
||||
@@ -174,98 +189,69 @@ export async function getOrganizationBillingData(
|
||||
}
|
||||
|
||||
/**
|
||||
* Update usage limit for a specific organization member
|
||||
* Update organization usage limit (cap)
|
||||
*/
|
||||
export async function updateMemberUsageLimit(
|
||||
export async function updateOrganizationUsageLimit(
|
||||
organizationId: string,
|
||||
memberId: string,
|
||||
newLimit: number,
|
||||
adminUserId: string
|
||||
): Promise<void> {
|
||||
newLimit: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Verify admin has permission to modify limits
|
||||
const adminMemberRecord = await db
|
||||
// Validate the organization exists
|
||||
const orgRecord = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, adminUserId)))
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (adminMemberRecord.length === 0 || !['owner', 'admin'].includes(adminMemberRecord[0].role)) {
|
||||
throw new Error('Insufficient permissions to modify usage limits')
|
||||
if (orgRecord.length === 0) {
|
||||
return { success: false, error: 'Organization not found' }
|
||||
}
|
||||
|
||||
// Verify member exists in organization
|
||||
const targetMemberRecord = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.limit(1)
|
||||
|
||||
if (targetMemberRecord.length === 0) {
|
||||
throw new Error('Member not found in organization')
|
||||
}
|
||||
|
||||
// Get organization subscription to validate limit
|
||||
// Get subscription to validate minimum
|
||||
const subscription = await getOrganizationSubscription(organizationId)
|
||||
if (!subscription) {
|
||||
throw new Error('No active subscription found')
|
||||
return { success: false, error: 'No active subscription found' }
|
||||
}
|
||||
|
||||
// Validate minimum limit based on plan
|
||||
const planLimits = {
|
||||
free: DEFAULT_FREE_CREDITS,
|
||||
pro: 20,
|
||||
team: 40,
|
||||
enterprise: 100, // Default, can be overridden by metadata
|
||||
}
|
||||
// Calculate minimum based on seats
|
||||
const { basePrice } = getPlanPricing(subscription.plan, subscription)
|
||||
const minimumLimit = Math.max(subscription.seats || 1, 1) * basePrice
|
||||
|
||||
let minimumLimit =
|
||||
planLimits[subscription.plan as keyof typeof planLimits] || DEFAULT_FREE_CREDITS
|
||||
|
||||
// For enterprise, check metadata for custom limits
|
||||
if (subscription.plan === 'enterprise' && subscription.metadata) {
|
||||
try {
|
||||
const metadata =
|
||||
typeof subscription.metadata === 'string'
|
||||
? JSON.parse(subscription.metadata)
|
||||
: subscription.metadata
|
||||
if (metadata.perSeatAllowance) {
|
||||
minimumLimit = metadata.perSeatAllowance
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse subscription metadata', { error: e })
|
||||
// Validate new limit is not below minimum
|
||||
if (newLimit < minimumLimit) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Usage limit cannot be less than minimum billing amount of $${roundCurrency(minimumLimit).toFixed(2)}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (newLimit < minimumLimit) {
|
||||
throw new Error(`Usage limit cannot be below $${minimumLimit} for ${subscription.plan} plan`)
|
||||
}
|
||||
|
||||
// Update the member's usage limit
|
||||
// Update the organization usage limit
|
||||
// Convert number to string for decimal column
|
||||
await db
|
||||
.update(userStats)
|
||||
.update(organization)
|
||||
.set({
|
||||
currentUsageLimit: newLimit.toString(),
|
||||
usageLimitSetBy: adminUserId,
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
orgUsageLimit: roundCurrency(newLimit).toFixed(2),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, memberId))
|
||||
.where(eq(organization.id, organizationId))
|
||||
|
||||
logger.info('Updated member usage limit', {
|
||||
logger.info('Organization usage limit updated', {
|
||||
organizationId,
|
||||
memberId,
|
||||
newLimit,
|
||||
adminUserId,
|
||||
minimumLimit,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('Failed to update member usage limit', {
|
||||
logger.error('Failed to update organization usage limit', {
|
||||
organizationId,
|
||||
memberId,
|
||||
newLimit,
|
||||
adminUserId,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to update usage limit',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import {
|
||||
calculateDefaultUsageLimit,
|
||||
checkEnterprisePlan,
|
||||
checkProPlan,
|
||||
checkTeamPlan,
|
||||
getFreeTierLimit,
|
||||
getPerUserMinimumLimit,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { UserSubscriptionState } from '@/lib/billing/types'
|
||||
import { isProd } from '@/lib/environment'
|
||||
@@ -157,9 +156,9 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
// Calculate usage limit
|
||||
let limit = DEFAULT_FREE_CREDITS // Default free tier limit
|
||||
let limit = getFreeTierLimit() // Default free tier limit
|
||||
if (subscription) {
|
||||
limit = calculateDefaultUsageLimit(subscription)
|
||||
limit = getPerUserMinimumLimit(subscription)
|
||||
logger.info('Using subscription-based limit', {
|
||||
userId,
|
||||
plan: subscription.plan,
|
||||
@@ -194,86 +193,7 @@ export async function hasExceededCostLimit(userId: string): Promise<boolean> {
|
||||
/**
|
||||
* Check if sharing features are enabled for user
|
||||
*/
|
||||
export async function isSharingEnabled(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
if (!subscription) {
|
||||
return false // Free users don't have sharing
|
||||
}
|
||||
|
||||
// Use Better-Auth client to check feature flags
|
||||
const { data: subscriptions } = await client.subscription.list({
|
||||
query: { referenceId: subscription.referenceId },
|
||||
})
|
||||
|
||||
const activeSubscription = subscriptions?.find((sub) => sub.status === 'active')
|
||||
return !!activeSubscription?.limits?.sharingEnabled
|
||||
} catch (error) {
|
||||
logger.error('Error checking sharing permission', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if multiplayer features are enabled for user
|
||||
*/
|
||||
export async function isMultiplayerEnabled(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
if (!subscription) {
|
||||
return false // Free users don't have multiplayer
|
||||
}
|
||||
|
||||
// Use Better-Auth client to check feature flags
|
||||
const { data: subscriptions } = await client.subscription.list({
|
||||
query: { referenceId: subscription.referenceId },
|
||||
})
|
||||
|
||||
const activeSubscription = subscriptions?.find((sub) => sub.status === 'active')
|
||||
return !!activeSubscription?.limits?.multiplayerEnabled
|
||||
} catch (error) {
|
||||
logger.error('Error checking multiplayer permission', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if workspace collaboration features are enabled for user
|
||||
*/
|
||||
export async function isWorkspaceCollaborationEnabled(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
if (!subscription) {
|
||||
return false // Free users don't have workspace collaboration
|
||||
}
|
||||
|
||||
// Use Better-Auth client to check feature flags
|
||||
const { data: subscriptions } = await client.subscription.list({
|
||||
query: { referenceId: subscription.referenceId },
|
||||
})
|
||||
|
||||
const activeSubscription = subscriptions?.find((sub) => sub.status === 'active')
|
||||
return !!activeSubscription?.limits?.workspaceCollaborationEnabled
|
||||
} catch (error) {
|
||||
logger.error('Error checking workspace collaboration permission', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Removed unused feature flag helpers: isSharingEnabled, isMultiplayerEnabled, isWorkspaceCollaborationEnabled
|
||||
|
||||
/**
|
||||
* Get comprehensive subscription state for a user
|
||||
@@ -306,42 +226,12 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
|
||||
else if (isTeam) planName = 'team'
|
||||
else if (isPro) planName = 'pro'
|
||||
|
||||
// Check features based on subscription (avoid redundant better-auth calls)
|
||||
let sharingEnabled = false
|
||||
let multiplayerEnabled = false
|
||||
let workspaceCollaborationEnabled = false
|
||||
|
||||
if (!isProd || subscription) {
|
||||
if (!isProd) {
|
||||
// Development mode - enable all features
|
||||
sharingEnabled = true
|
||||
multiplayerEnabled = true
|
||||
workspaceCollaborationEnabled = true
|
||||
} else {
|
||||
// Production mode - check subscription features
|
||||
try {
|
||||
const { data: subscriptions } = await client.subscription.list({
|
||||
query: { referenceId: subscription.referenceId },
|
||||
})
|
||||
const activeSubscription = subscriptions?.find((sub) => sub.status === 'active')
|
||||
|
||||
sharingEnabled = !!activeSubscription?.limits?.sharingEnabled
|
||||
multiplayerEnabled = !!activeSubscription?.limits?.multiplayerEnabled
|
||||
workspaceCollaborationEnabled =
|
||||
!!activeSubscription?.limits?.workspaceCollaborationEnabled
|
||||
} catch (error) {
|
||||
logger.error('Error checking subscription features', { error, userId })
|
||||
// Default to false on error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check cost limit using already-fetched user stats
|
||||
let hasExceededLimit = false
|
||||
if (isProd && statsRecords.length > 0) {
|
||||
let limit = DEFAULT_FREE_CREDITS // Default free tier limit
|
||||
let limit = getFreeTierLimit() // Default free tier limit
|
||||
if (subscription) {
|
||||
limit = calculateDefaultUsageLimit(subscription)
|
||||
limit = getPerUserMinimumLimit(subscription)
|
||||
}
|
||||
|
||||
const currentCost = Number.parseFloat(
|
||||
@@ -356,11 +246,6 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
|
||||
isEnterprise,
|
||||
isFree,
|
||||
highestPrioritySubscription: subscription,
|
||||
features: {
|
||||
sharingEnabled,
|
||||
multiplayerEnabled,
|
||||
workspaceCollaborationEnabled,
|
||||
},
|
||||
hasExceededLimit,
|
||||
planName,
|
||||
}
|
||||
@@ -374,11 +259,6 @@ export async function getUserSubscriptionState(userId: string): Promise<UserSubs
|
||||
isEnterprise: false,
|
||||
isFree: true,
|
||||
highestPrioritySubscription: null,
|
||||
features: {
|
||||
sharingEnabled: false,
|
||||
multiplayerEnabled: false,
|
||||
workspaceCollaborationEnabled: false,
|
||||
},
|
||||
hasExceededLimit: false,
|
||||
planName: 'free',
|
||||
}
|
||||
|
||||
@@ -1,52 +1,88 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { calculateDefaultUsageLimit, canEditUsageLimit } from '@/lib/billing/subscriptions/utils'
|
||||
import {
|
||||
canEditUsageLimit,
|
||||
getFreeTierLimit,
|
||||
getPerUserMinimumLimit,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member, user, userStats } from '@/db/schema'
|
||||
import { member, organization, user, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('UsageManagement')
|
||||
|
||||
/**
|
||||
* Consolidated usage management module
|
||||
* Handles user usage tracking, limits, and monitoring
|
||||
* Handle new user setup when they join the platform
|
||||
* Creates userStats record with default free credits
|
||||
*/
|
||||
export async function handleNewUser(userId: string): Promise<void> {
|
||||
try {
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: userId,
|
||||
currentUsageLimit: getFreeTierLimit().toString(),
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
|
||||
logger.info('User stats record created for new user', { userId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to create user stats record for new user', {
|
||||
userId,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive usage data for a user
|
||||
*/
|
||||
export async function getUserUsageData(userId: string): Promise<UsageData> {
|
||||
try {
|
||||
const userStatsData = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
const [userStatsData, subscription] = await Promise.all([
|
||||
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
|
||||
getHighestPrioritySubscription(userId),
|
||||
])
|
||||
|
||||
if (userStatsData.length === 0) {
|
||||
// Initialize user stats if they don't exist
|
||||
await initializeUserUsageLimit(userId)
|
||||
return {
|
||||
currentUsage: 0,
|
||||
limit: DEFAULT_FREE_CREDITS,
|
||||
percentUsed: 0,
|
||||
isWarning: false,
|
||||
isExceeded: false,
|
||||
billingPeriodStart: null,
|
||||
billingPeriodEnd: null,
|
||||
lastPeriodCost: 0,
|
||||
}
|
||||
throw new Error(`User stats not found for userId: ${userId}`)
|
||||
}
|
||||
|
||||
const stats = userStatsData[0]
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
const currentUsage = Number.parseFloat(
|
||||
stats.currentPeriodCost?.toString() ?? stats.totalCost.toString()
|
||||
)
|
||||
const limit = Number.parseFloat(stats.currentUsageLimit)
|
||||
const percentUsed = limit > 0 ? Math.round((currentUsage / limit) * 100) : 0
|
||||
|
||||
// Determine usage limit based on plan type
|
||||
let limit: number
|
||||
|
||||
if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') {
|
||||
// Free/Pro: Use individual user limit from userStats
|
||||
limit = stats.currentUsageLimit
|
||||
? Number.parseFloat(stats.currentUsageLimit)
|
||||
: getFreeTierLimit()
|
||||
} else {
|
||||
// Team/Enterprise: Use organization limit but never below minimum (seats × cost per seat)
|
||||
const orgData = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(subscription.plan, subscription)
|
||||
const minimum = (subscription.seats || 1) * basePrice
|
||||
|
||||
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
|
||||
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
|
||||
limit = Math.max(configured, minimum)
|
||||
} else {
|
||||
limit = minimum
|
||||
}
|
||||
}
|
||||
|
||||
const percentUsed = limit > 0 ? Math.min(Math.floor((currentUsage / limit) * 100), 100) : 0
|
||||
const isWarning = percentUsed >= 80
|
||||
const isExceeded = currentUsage >= limit
|
||||
|
||||
@@ -76,84 +112,56 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
|
||||
*/
|
||||
export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitInfo> {
|
||||
try {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
// For team plans, check if user is owner/admin to determine if they can edit their own limit
|
||||
let canEdit = canEditUsageLimit(subscription)
|
||||
|
||||
if (subscription?.plan === 'team') {
|
||||
// For team plans, the subscription referenceId should be the organization ID
|
||||
// Check user's role in that organization
|
||||
const orgMemberRecord = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, subscription.referenceId)))
|
||||
.limit(1)
|
||||
|
||||
if (orgMemberRecord.length > 0) {
|
||||
const userRole = orgMemberRecord[0].role
|
||||
// Team owners and admins can edit their own usage limits
|
||||
// Regular team members cannot edit their own limits
|
||||
canEdit = canEdit && ['owner', 'admin'].includes(userRole)
|
||||
} else {
|
||||
// User is not a member of the organization, should not be able to edit
|
||||
canEdit = false
|
||||
}
|
||||
}
|
||||
|
||||
// Use plan-based minimums instead of role-based minimums
|
||||
let minimumLimit: number
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
// Free plan users
|
||||
minimumLimit = DEFAULT_FREE_CREDITS
|
||||
} else if (subscription.plan === 'pro') {
|
||||
// Pro plan users: $20 minimum
|
||||
minimumLimit = 20
|
||||
} else if (subscription.plan === 'team') {
|
||||
// Team plan users: $40 minimum (per-seat allocation, regardless of role)
|
||||
minimumLimit = 40
|
||||
} else if (subscription.plan === 'enterprise') {
|
||||
// Enterprise plan users: per-seat allocation from their plan
|
||||
const metadata = subscription.metadata || {}
|
||||
if (metadata.perSeatAllowance) {
|
||||
minimumLimit = Number.parseFloat(metadata.perSeatAllowance)
|
||||
} else if (metadata.totalAllowance) {
|
||||
// For total allowance, use per-seat calculation
|
||||
const seats = subscription.seats || 1
|
||||
minimumLimit = Number.parseFloat(metadata.totalAllowance) / seats
|
||||
} else {
|
||||
minimumLimit = 200 // Default enterprise per-seat limit
|
||||
}
|
||||
} else {
|
||||
// Fallback to plan-based calculation
|
||||
minimumLimit = calculateDefaultUsageLimit(subscription)
|
||||
}
|
||||
|
||||
const userStatsRecord = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
const [subscription, userStatsRecord] = await Promise.all([
|
||||
getHighestPrioritySubscription(userId),
|
||||
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
|
||||
])
|
||||
|
||||
if (userStatsRecord.length === 0) {
|
||||
await initializeUserUsageLimit(userId)
|
||||
return {
|
||||
currentLimit: DEFAULT_FREE_CREDITS,
|
||||
canEdit: false,
|
||||
minimumLimit: DEFAULT_FREE_CREDITS,
|
||||
plan: 'free',
|
||||
setBy: null,
|
||||
updatedAt: null,
|
||||
}
|
||||
throw new Error(`User stats not found for userId: ${userId}`)
|
||||
}
|
||||
|
||||
const stats = userStatsRecord[0]
|
||||
|
||||
// Determine limits based on plan type
|
||||
let currentLimit: number
|
||||
let minimumLimit: number
|
||||
let canEdit: boolean
|
||||
|
||||
if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') {
|
||||
// Free/Pro: Use individual limits
|
||||
currentLimit = stats.currentUsageLimit
|
||||
? Number.parseFloat(stats.currentUsageLimit)
|
||||
: getFreeTierLimit()
|
||||
minimumLimit = getPerUserMinimumLimit(subscription)
|
||||
canEdit = canEditUsageLimit(subscription)
|
||||
} else {
|
||||
// Team/Enterprise: Use organization limits (users cannot edit)
|
||||
const orgData = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(subscription.plan, subscription)
|
||||
const minimum = (subscription.seats || 1) * basePrice
|
||||
|
||||
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
|
||||
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
|
||||
currentLimit = Math.max(configured, minimum)
|
||||
} else {
|
||||
currentLimit = minimum
|
||||
}
|
||||
minimumLimit = minimum
|
||||
canEdit = false // Team/enterprise members cannot edit limits
|
||||
}
|
||||
|
||||
return {
|
||||
currentLimit: Number.parseFloat(stats.currentUsageLimit),
|
||||
currentLimit,
|
||||
canEdit,
|
||||
minimumLimit,
|
||||
plan: subscription?.plan || 'free',
|
||||
setBy: stats.usageLimitSetBy,
|
||||
updatedAt: stats.usageLimitUpdatedAt,
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -166,32 +174,36 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
|
||||
* Initialize usage limits for a new user
|
||||
*/
|
||||
export async function initializeUserUsageLimit(userId: string): Promise<void> {
|
||||
try {
|
||||
// Check if user already has usage stats
|
||||
const existingStats = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
// Check if user already has usage stats
|
||||
const existingStats = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existingStats.length > 0) {
|
||||
return // User already has usage stats, don't override
|
||||
}
|
||||
|
||||
// Create initial usage stats with default free credits limit
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
currentUsageLimit: DEFAULT_FREE_CREDITS.toString(), // Default free credits for new users
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
billingPeriodStart: new Date(), // Start billing period immediately
|
||||
})
|
||||
|
||||
logger.info('Initialized usage limit for new user', { userId, limit: DEFAULT_FREE_CREDITS })
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize usage limit', { userId, error })
|
||||
throw error
|
||||
if (existingStats.length > 0) {
|
||||
return // User already has usage stats
|
||||
}
|
||||
|
||||
// Check user's subscription to determine initial limit
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
const isTeamOrEnterprise =
|
||||
subscription && (subscription.plan === 'team' || subscription.plan === 'enterprise')
|
||||
|
||||
// Create initial usage stats
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
// Team/enterprise: null (use org limit), Free/Pro: individual limit
|
||||
currentUsageLimit: isTeamOrEnterprise ? null : getFreeTierLimit().toString(),
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
|
||||
logger.info('Initialized user stats', {
|
||||
userId,
|
||||
plan: subscription?.plan || 'free',
|
||||
hasIndividualLimit: !isTeamOrEnterprise,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,64 +217,20 @@ export async function updateUserUsageLimit(
|
||||
try {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
// Check if user can edit limits
|
||||
let canEdit = canEditUsageLimit(subscription)
|
||||
|
||||
if (subscription?.plan === 'team') {
|
||||
// For team plans, the subscription referenceId should be the organization ID
|
||||
// Check user's role in that organization
|
||||
const orgMemberRecord = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, subscription.referenceId)))
|
||||
.limit(1)
|
||||
|
||||
if (orgMemberRecord.length > 0) {
|
||||
const userRole = orgMemberRecord[0].role
|
||||
// Team owners and admins can edit their own usage limits
|
||||
// Regular team members cannot edit their own limits
|
||||
canEdit = canEdit && ['owner', 'admin'].includes(userRole)
|
||||
} else {
|
||||
// User is not a member of the organization, should not be able to edit
|
||||
canEdit = false
|
||||
// Team/enterprise users don't have individual limits
|
||||
if (subscription && (subscription.plan === 'team' || subscription.plan === 'enterprise')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Team and enterprise members use organization limits',
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) {
|
||||
if (subscription?.plan === 'team') {
|
||||
return { success: false, error: 'Only team owners and admins can edit usage limits' }
|
||||
}
|
||||
// Only pro users can edit limits (free users cannot)
|
||||
if (!subscription || subscription.plan === 'free') {
|
||||
return { success: false, error: 'Free plan users cannot edit usage limits' }
|
||||
}
|
||||
|
||||
// Use plan-based minimums instead of role-based minimums
|
||||
let minimumLimit: number
|
||||
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
// Free plan users (shouldn't reach here due to canEditUsageLimit check above)
|
||||
minimumLimit = DEFAULT_FREE_CREDITS
|
||||
} else if (subscription.plan === 'pro') {
|
||||
// Pro plan users: $20 minimum
|
||||
minimumLimit = 20
|
||||
} else if (subscription.plan === 'team') {
|
||||
// Team plan users: $40 minimum (per-seat allocation, regardless of role)
|
||||
minimumLimit = 40
|
||||
} else if (subscription.plan === 'enterprise') {
|
||||
// Enterprise plan users: per-seat allocation from their plan
|
||||
const metadata = subscription.metadata || {}
|
||||
if (metadata.perSeatAllowance) {
|
||||
minimumLimit = Number.parseFloat(metadata.perSeatAllowance)
|
||||
} else if (metadata.totalAllowance) {
|
||||
// For total allowance, use per-seat calculation
|
||||
const seats = subscription.seats || 1
|
||||
minimumLimit = Number.parseFloat(metadata.totalAllowance) / seats
|
||||
} else {
|
||||
minimumLimit = 200 // Default enterprise per-seat limit
|
||||
}
|
||||
} else {
|
||||
// Fallback to plan-based calculation
|
||||
minimumLimit = calculateDefaultUsageLimit(subscription)
|
||||
}
|
||||
const minimumLimit = getPerUserMinimumLimit(subscription)
|
||||
|
||||
logger.info('Applying plan-based validation', {
|
||||
userId,
|
||||
@@ -305,7 +273,6 @@ export async function updateUserUsageLimit(
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentUsageLimit: newLimit.toString(),
|
||||
usageLimitSetBy: setBy || userId,
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
@@ -326,27 +293,57 @@ export async function updateUserUsageLimit(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage limit for a user (simple version)
|
||||
* Get usage limit for a user (used by checkUsageStatus for server-side checks)
|
||||
* Free/Pro: Individual user limit from userStats
|
||||
* Team/Enterprise: Organization limit
|
||||
*/
|
||||
export async function getUserUsageLimit(userId: string): Promise<number> {
|
||||
try {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') {
|
||||
// Free/Pro: Use individual limit from userStats
|
||||
const userStatsQuery = await db
|
||||
.select()
|
||||
.select({ currentUsageLimit: userStats.currentUsageLimit })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (userStatsQuery.length === 0) {
|
||||
// User doesn't have stats yet, initialize and return default
|
||||
await initializeUserUsageLimit(userId)
|
||||
return DEFAULT_FREE_CREDITS // Default free plan limit
|
||||
throw new Error(`User stats not found for userId: ${userId}`)
|
||||
}
|
||||
|
||||
// Individual limits should never be null for free/pro users
|
||||
if (!userStatsQuery[0].currentUsageLimit) {
|
||||
throw new Error(
|
||||
`Invalid null usage limit for ${subscription?.plan || 'free'} user: ${userId}`
|
||||
)
|
||||
}
|
||||
|
||||
return Number.parseFloat(userStatsQuery[0].currentUsageLimit)
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user usage limit', { userId, error })
|
||||
return 5 // Fallback to safe default
|
||||
}
|
||||
// Team/Enterprise: Use organization limit but never below minimum
|
||||
const orgData = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (orgData.length === 0) {
|
||||
throw new Error(`Organization not found: ${subscription.referenceId}`)
|
||||
}
|
||||
|
||||
if (orgData[0].orgUsageLimit) {
|
||||
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(subscription.plan, subscription)
|
||||
const minimum = (subscription.seats || 1) * basePrice
|
||||
return Math.max(configured, minimum)
|
||||
}
|
||||
|
||||
// If org hasn't set a custom limit, use minimum (seats × cost per seat)
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(subscription.plan, subscription)
|
||||
return (subscription.seats || 1) * basePrice
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,65 +377,68 @@ export async function checkUsageStatus(userId: string): Promise<{
|
||||
* Sync usage limits based on subscription changes
|
||||
*/
|
||||
export async function syncUsageLimitsFromSubscription(userId: string): Promise<void> {
|
||||
try {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
const defaultLimit = calculateDefaultUsageLimit(subscription)
|
||||
const [subscription, currentUserStats] = await Promise.all([
|
||||
getHighestPrioritySubscription(userId),
|
||||
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
|
||||
])
|
||||
|
||||
// Get current user stats
|
||||
const currentUserStats = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
if (currentUserStats.length === 0) {
|
||||
throw new Error(`User stats not found for userId: ${userId}`)
|
||||
}
|
||||
|
||||
if (currentUserStats.length === 0) {
|
||||
// Create new user stats with default limit
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
const currentStats = currentUserStats[0]
|
||||
|
||||
// Team/enterprise: Should have null individual limits
|
||||
if (subscription && (subscription.plan === 'team' || subscription.plan === 'enterprise')) {
|
||||
if (currentStats.currentUsageLimit !== null) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentUsageLimit: null,
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Cleared individual limit for team/enterprise member', {
|
||||
userId,
|
||||
plan: subscription.plan,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Free/Pro: Handle individual limits
|
||||
const defaultLimit = getPerUserMinimumLimit(subscription)
|
||||
const currentLimit = currentStats.currentUsageLimit
|
||||
? Number.parseFloat(currentStats.currentUsageLimit)
|
||||
: 0
|
||||
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
// Downgraded to free
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentUsageLimit: getFreeTierLimit().toString(),
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Set limit to free tier', { userId })
|
||||
} else if (currentLimit < defaultLimit) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentUsageLimit: defaultLimit.toString(),
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
logger.info('Created usage stats with synced limit', { userId, limit: defaultLimit })
|
||||
return
|
||||
}
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
const currentStats = currentUserStats[0]
|
||||
const currentLimit = Number.parseFloat(currentStats.currentUsageLimit)
|
||||
|
||||
// Only update if subscription is free plan or if current limit is below new minimum
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
// User downgraded to free plan - cap at default free credits
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentUsageLimit: DEFAULT_FREE_CREDITS.toString(),
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Synced usage limit to free plan', { userId, limit: DEFAULT_FREE_CREDITS })
|
||||
} else if (currentLimit < defaultLimit) {
|
||||
// User upgraded and current limit is below new minimum - raise to minimum
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentUsageLimit: defaultLimit.toString(),
|
||||
usageLimitUpdatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Synced usage limit to new minimum', {
|
||||
userId,
|
||||
oldLimit: currentLimit,
|
||||
newLimit: defaultLimit,
|
||||
})
|
||||
}
|
||||
// If user has higher custom limit, keep it unchanged
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync usage limits', { userId, error })
|
||||
throw error
|
||||
logger.info('Raised limit to plan minimum', {
|
||||
userId,
|
||||
newLimit: defaultLimit,
|
||||
})
|
||||
}
|
||||
// Keep higher custom limits unchanged
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,8 +453,6 @@ export async function getTeamUsageLimits(organizationId: string): Promise<
|
||||
currentUsage: number
|
||||
totalCost: number
|
||||
lastActive: Date | null
|
||||
limitSetBy: string | null
|
||||
limitUpdatedAt: Date | null
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
@@ -467,8 +465,6 @@ export async function getTeamUsageLimits(organizationId: string): Promise<
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
totalCost: userStats.totalCost,
|
||||
lastActive: userStats.lastActive,
|
||||
limitSetBy: userStats.usageLimitSetBy,
|
||||
limitUpdatedAt: userStats.usageLimitUpdatedAt,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
@@ -479,12 +475,10 @@ export async function getTeamUsageLimits(organizationId: string): Promise<
|
||||
userId: memberData.userId,
|
||||
userName: memberData.userName,
|
||||
userEmail: memberData.userEmail,
|
||||
currentLimit: Number.parseFloat(memberData.currentLimit || DEFAULT_FREE_CREDITS.toString()),
|
||||
currentLimit: Number.parseFloat(memberData.currentLimit || getFreeTierLimit().toString()),
|
||||
currentUsage: Number.parseFloat(memberData.currentPeriodCost || '0'),
|
||||
totalCost: Number.parseFloat(memberData.totalCost || '0'),
|
||||
lastActive: memberData.lastActive,
|
||||
limitSetBy: memberData.limitSetBy,
|
||||
limitUpdatedAt: memberData.limitUpdatedAt,
|
||||
}))
|
||||
} catch (error) {
|
||||
logger.error('Failed to get team usage limits', { organizationId, error })
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
export * from '@/lib/billing/calculations/usage-monitor'
|
||||
export * from '@/lib/billing/core/billing'
|
||||
export * from '@/lib/billing/core/billing-periods'
|
||||
export * from '@/lib/billing/core/organization-billing'
|
||||
export * from '@/lib/billing/core/subscription'
|
||||
export {
|
||||
@@ -25,9 +24,9 @@ export {
|
||||
} from '@/lib/billing/core/usage'
|
||||
export * from '@/lib/billing/subscriptions/utils'
|
||||
export {
|
||||
calculateDefaultUsageLimit as getDefaultLimit,
|
||||
canEditUsageLimit as canEditLimit,
|
||||
getMinimumUsageLimit as getMinimumLimit,
|
||||
getSubscriptionAllowance as getDefaultLimit,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
export * from '@/lib/billing/types'
|
||||
export * from '@/lib/billing/validation/seat-management'
|
||||
|
||||
184
apps/sim/lib/billing/organization.ts
Normal file
184
apps/sim/lib/billing/organization.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
|
||||
const logger = createLogger('BillingOrganization')
|
||||
|
||||
type SubscriptionData = {
|
||||
id: string
|
||||
plan: string
|
||||
referenceId: string
|
||||
status: string
|
||||
seats?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user already owns an organization
|
||||
*/
|
||||
async function getUserOwnedOrganization(userId: string): Promise<string | null> {
|
||||
const existingMemberships = await db
|
||||
.select({ organizationId: schema.member.organizationId })
|
||||
.from(schema.member)
|
||||
.where(and(eq(schema.member.userId, userId), eq(schema.member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (existingMemberships.length > 0) {
|
||||
const [existingOrg] = await db
|
||||
.select({ id: schema.organization.id })
|
||||
.from(schema.organization)
|
||||
.where(eq(schema.organization.id, existingMemberships[0].organizationId))
|
||||
.limit(1)
|
||||
|
||||
return existingOrg?.id || null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new organization and add user as owner
|
||||
*/
|
||||
async function createOrganizationWithOwner(
|
||||
userId: string,
|
||||
organizationName: string,
|
||||
organizationSlug: string,
|
||||
metadata: Record<string, any> = {}
|
||||
): Promise<string> {
|
||||
const orgId = `org_${crypto.randomUUID()}`
|
||||
|
||||
const [newOrg] = await db
|
||||
.insert(schema.organization)
|
||||
.values({
|
||||
id: orgId,
|
||||
name: organizationName,
|
||||
slug: organizationSlug,
|
||||
metadata,
|
||||
})
|
||||
.returning({ id: schema.organization.id })
|
||||
|
||||
// Add user as owner/admin of the organization
|
||||
await db.insert(schema.member).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: userId,
|
||||
organizationId: newOrg.id,
|
||||
role: 'owner',
|
||||
})
|
||||
|
||||
logger.info('Created organization with owner', {
|
||||
userId,
|
||||
organizationId: newOrg.id,
|
||||
organizationName,
|
||||
})
|
||||
|
||||
return newOrg.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization for team/enterprise plan upgrade
|
||||
*/
|
||||
export async function createOrganizationForTeamPlan(
|
||||
userId: string,
|
||||
userName?: string,
|
||||
userEmail?: string,
|
||||
organizationSlug?: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Check if user already owns an organization
|
||||
const existingOrgId = await getUserOwnedOrganization(userId)
|
||||
if (existingOrgId) {
|
||||
return existingOrgId
|
||||
}
|
||||
|
||||
// Create new organization (same naming for both team and enterprise)
|
||||
const organizationName = userName || `${userEmail || 'User'}'s Team`
|
||||
const slug = organizationSlug || `${userId}-team-${Date.now()}`
|
||||
|
||||
const orgId = await createOrganizationWithOwner(userId, organizationName, slug, {
|
||||
createdForTeamPlan: true,
|
||||
originalUserId: userId,
|
||||
})
|
||||
|
||||
logger.info('Created organization for team/enterprise plan', {
|
||||
userId,
|
||||
organizationId: orgId,
|
||||
organizationName,
|
||||
})
|
||||
|
||||
return orgId
|
||||
} catch (error) {
|
||||
logger.error('Failed to create organization for team/enterprise plan', {
|
||||
userId,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync usage limits for subscription members
|
||||
* Updates usage limits for all users associated with the subscription
|
||||
*/
|
||||
export async function syncSubscriptionUsageLimits(subscription: SubscriptionData) {
|
||||
try {
|
||||
logger.info('Syncing subscription usage limits', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
plan: subscription.plan,
|
||||
})
|
||||
|
||||
// Check if this is a user or organization subscription
|
||||
const users = await db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (users.length > 0) {
|
||||
// Individual user subscription - sync their usage limits
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
|
||||
logger.info('Synced usage limits for individual user subscription', {
|
||||
userId: subscription.referenceId,
|
||||
subscriptionId: subscription.id,
|
||||
plan: subscription.plan,
|
||||
})
|
||||
} else {
|
||||
// Organization subscription - sync usage limits for all members
|
||||
const members = await db
|
||||
.select({ userId: schema.member.userId })
|
||||
.from(schema.member)
|
||||
.where(eq(schema.member.organizationId, subscription.referenceId))
|
||||
|
||||
if (members.length > 0) {
|
||||
for (const member of members) {
|
||||
try {
|
||||
await syncUsageLimitsFromSubscription(member.userId)
|
||||
} catch (memberError) {
|
||||
logger.error('Failed to sync usage limits for organization member', {
|
||||
userId: member.userId,
|
||||
organizationId: subscription.referenceId,
|
||||
subscriptionId: subscription.id,
|
||||
error: memberError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Synced usage limits for organization members', {
|
||||
organizationId: subscription.referenceId,
|
||||
memberCount: members.length,
|
||||
subscriptionId: subscription.id,
|
||||
plan: subscription.plan,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync subscription usage limits', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
65
apps/sim/lib/billing/plans.ts
Normal file
65
apps/sim/lib/billing/plans.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
getFreeTierLimit,
|
||||
getProTierLimit,
|
||||
getTeamTierLimitPerSeat,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
export interface BillingPlan {
|
||||
name: string
|
||||
priceId: string
|
||||
limits: {
|
||||
cost: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the billing plans configuration for Better Auth Stripe plugin
|
||||
*/
|
||||
export function getPlans(): BillingPlan[] {
|
||||
return [
|
||||
{
|
||||
name: 'free',
|
||||
priceId: env.STRIPE_FREE_PRICE_ID || '',
|
||||
limits: {
|
||||
cost: getFreeTierLimit(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pro',
|
||||
priceId: env.STRIPE_PRO_PRICE_ID || '',
|
||||
limits: {
|
||||
cost: getProTierLimit(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'team',
|
||||
priceId: env.STRIPE_TEAM_PRICE_ID || '',
|
||||
limits: {
|
||||
cost: getTeamTierLimitPerSeat(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'enterprise',
|
||||
priceId: 'price_dynamic',
|
||||
limits: {
|
||||
cost: getTeamTierLimitPerSeat(),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific plan by name
|
||||
*/
|
||||
export function getPlanByName(planName: string): BillingPlan | undefined {
|
||||
return getPlans().find((plan) => plan.name === planName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan limits for a given plan name
|
||||
*/
|
||||
export function getPlanLimits(planName: string): number {
|
||||
const plan = getPlanByName(planName)
|
||||
return plan?.limits.cost ?? getFreeTierLimit()
|
||||
}
|
||||
@@ -8,11 +8,7 @@ const logger = createLogger('StripeClient')
|
||||
* Check if Stripe credentials are valid
|
||||
*/
|
||||
export function hasValidStripeCredentials(): boolean {
|
||||
return !!(
|
||||
env.STRIPE_SECRET_KEY &&
|
||||
env.STRIPE_SECRET_KEY.trim() !== '' &&
|
||||
env.STRIPE_SECRET_KEY !== 'placeholder'
|
||||
)
|
||||
return !!env.STRIPE_SECRET_KEY
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { calculateDefaultUsageLimit, checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { checkEnterprisePlan, getSubscriptionAllowance } from '@/lib/billing/subscriptions/utils'
|
||||
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {
|
||||
@@ -28,50 +28,50 @@ describe('Subscription Utilities', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateDefaultUsageLimit', () => {
|
||||
describe('getSubscriptionAllowance', () => {
|
||||
it.concurrent('returns free-tier limit when subscription is null', () => {
|
||||
expect(calculateDefaultUsageLimit(null)).toBe(10)
|
||||
expect(getSubscriptionAllowance(null)).toBe(10)
|
||||
})
|
||||
|
||||
it.concurrent('returns free-tier limit when subscription is undefined', () => {
|
||||
expect(calculateDefaultUsageLimit(undefined)).toBe(10)
|
||||
expect(getSubscriptionAllowance(undefined)).toBe(10)
|
||||
})
|
||||
|
||||
it.concurrent('returns free-tier limit when subscription is not active', () => {
|
||||
expect(calculateDefaultUsageLimit({ plan: 'pro', status: 'canceled', seats: 1 })).toBe(10)
|
||||
expect(getSubscriptionAllowance({ plan: 'pro', status: 'canceled', seats: 1 })).toBe(10)
|
||||
})
|
||||
|
||||
it.concurrent('returns pro limit for active pro plan', () => {
|
||||
expect(calculateDefaultUsageLimit({ plan: 'pro', status: 'active', seats: 1 })).toBe(20)
|
||||
expect(getSubscriptionAllowance({ plan: 'pro', status: 'active', seats: 1 })).toBe(20)
|
||||
})
|
||||
|
||||
it.concurrent('returns team limit multiplied by seats', () => {
|
||||
expect(calculateDefaultUsageLimit({ plan: 'team', status: 'active', seats: 3 })).toBe(3 * 40)
|
||||
expect(getSubscriptionAllowance({ plan: 'team', status: 'active', seats: 3 })).toBe(3 * 40)
|
||||
})
|
||||
|
||||
it.concurrent('returns enterprise limit using perSeatAllowance metadata', () => {
|
||||
it.concurrent('returns enterprise limit using perSeatPrice metadata', () => {
|
||||
const sub = {
|
||||
plan: 'enterprise',
|
||||
status: 'active',
|
||||
seats: 10,
|
||||
metadata: { perSeatAllowance: '150' },
|
||||
metadata: { perSeatPrice: 150 },
|
||||
}
|
||||
expect(calculateDefaultUsageLimit(sub)).toBe(10 * 150)
|
||||
expect(getSubscriptionAllowance(sub)).toBe(10 * 150)
|
||||
})
|
||||
|
||||
it.concurrent('returns enterprise limit using totalAllowance metadata', () => {
|
||||
it.concurrent('returns enterprise limit using perSeatPrice as string', () => {
|
||||
const sub = {
|
||||
plan: 'enterprise',
|
||||
status: 'active',
|
||||
seats: 8,
|
||||
metadata: { totalAllowance: '5000' },
|
||||
metadata: { perSeatPrice: '250' },
|
||||
}
|
||||
expect(calculateDefaultUsageLimit(sub)).toBe(5000)
|
||||
expect(getSubscriptionAllowance(sub)).toBe(8 * 250)
|
||||
})
|
||||
|
||||
it.concurrent('falls back to default enterprise tier when metadata missing', () => {
|
||||
const sub = { plan: 'enterprise', status: 'active', seats: 2, metadata: {} }
|
||||
expect(calculateDefaultUsageLimit(sub)).toBe(2 * 200)
|
||||
expect(getSubscriptionAllowance(sub)).toBe(2 * 200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import {
|
||||
DEFAULT_ENTERPRISE_TIER_COST_LIMIT,
|
||||
DEFAULT_FREE_CREDITS,
|
||||
DEFAULT_PRO_TIER_COST_LIMIT,
|
||||
DEFAULT_TEAM_TIER_COST_LIMIT,
|
||||
} from '@/lib/billing/constants'
|
||||
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
/**
|
||||
* Get the free tier limit from env or fallback to default
|
||||
*/
|
||||
export function getFreeTierLimit(): number {
|
||||
return env.FREE_TIER_COST_LIMIT || DEFAULT_FREE_CREDITS
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pro tier limit from env or fallback to default
|
||||
*/
|
||||
export function getProTierLimit(): number {
|
||||
return env.PRO_TIER_COST_LIMIT || DEFAULT_PRO_TIER_COST_LIMIT
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the team tier limit per seat from env or fallback to default
|
||||
*/
|
||||
export function getTeamTierLimitPerSeat(): number {
|
||||
return env.TEAM_TIER_COST_LIMIT || DEFAULT_TEAM_TIER_COST_LIMIT
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the enterprise tier limit per seat from env or fallback to default
|
||||
*/
|
||||
export function getEnterpriseTierLimitPerSeat(): number {
|
||||
return env.ENTERPRISE_TIER_COST_LIMIT || DEFAULT_ENTERPRISE_TIER_COST_LIMIT
|
||||
}
|
||||
|
||||
export function checkEnterprisePlan(subscription: any): boolean {
|
||||
return subscription?.plan === 'enterprise' && subscription?.status === 'active'
|
||||
}
|
||||
@@ -14,39 +48,82 @@ export function checkTeamPlan(subscription: any): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate default usage limit for a subscription based on its type and metadata
|
||||
* This is now used as the minimum limit for paid plans
|
||||
* Calculate the total subscription-level allowance (what the org/user gets for their base payment)
|
||||
* - Pro: Fixed amount per user
|
||||
* - Team: Seats * base price (pooled for the org)
|
||||
* - Enterprise: Seats * per-seat price (pooled, with optional custom pricing in metadata)
|
||||
* @param subscription The subscription object
|
||||
* @returns The calculated default usage limit in dollars
|
||||
* @returns The total subscription allowance in dollars
|
||||
*/
|
||||
export function calculateDefaultUsageLimit(subscription: any): number {
|
||||
export function getSubscriptionAllowance(subscription: any): number {
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
return env.FREE_TIER_COST_LIMIT || DEFAULT_FREE_CREDITS
|
||||
return getFreeTierLimit()
|
||||
}
|
||||
|
||||
const seats = subscription.seats || 1
|
||||
|
||||
if (subscription.plan === 'pro') {
|
||||
return env.PRO_TIER_COST_LIMIT || 0
|
||||
return getProTierLimit()
|
||||
}
|
||||
if (subscription.plan === 'team') {
|
||||
return seats * (env.TEAM_TIER_COST_LIMIT || 0)
|
||||
return seats * getTeamTierLimitPerSeat()
|
||||
}
|
||||
if (subscription.plan === 'enterprise') {
|
||||
const metadata = subscription.metadata || {}
|
||||
const metadata = subscription.metadata as EnterpriseSubscriptionMetadata | undefined
|
||||
|
||||
if (metadata.perSeatAllowance) {
|
||||
return seats * Number.parseFloat(metadata.perSeatAllowance)
|
||||
// Enterprise uses per-seat pricing (pooled like Team)
|
||||
// Custom per-seat price can be set in metadata
|
||||
let perSeatPrice = getEnterpriseTierLimitPerSeat()
|
||||
if (metadata?.perSeatPrice) {
|
||||
const parsed = Number.parseFloat(String(metadata.perSeatPrice))
|
||||
if (parsed > 0 && !Number.isNaN(parsed)) {
|
||||
perSeatPrice = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.totalAllowance) {
|
||||
return Number.parseFloat(metadata.totalAllowance)
|
||||
}
|
||||
|
||||
return seats * (env.ENTERPRISE_TIER_COST_LIMIT || 0)
|
||||
return seats * perSeatPrice
|
||||
}
|
||||
|
||||
return env.FREE_TIER_COST_LIMIT || DEFAULT_FREE_CREDITS
|
||||
return getFreeTierLimit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum usage limit for an individual user (used for validation)
|
||||
* - Pro: User's plan minimum
|
||||
* - Team: 0 (pooled model, no individual minimums)
|
||||
* - Enterprise: 0 (pooled model, no individual minimums)
|
||||
* @param subscription The subscription object
|
||||
* @returns The per-user minimum limit in dollars
|
||||
*/
|
||||
export function getPerUserMinimumLimit(subscription: any): number {
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
return getFreeTierLimit()
|
||||
}
|
||||
|
||||
const seats = subscription.seats || 1
|
||||
|
||||
if (subscription.plan === 'pro') {
|
||||
return getProTierLimit()
|
||||
}
|
||||
if (subscription.plan === 'team') {
|
||||
// For team plans, return the total pooled limit (seats * cost per seat)
|
||||
// This becomes the user's individual limit representing their share of the team pool
|
||||
return seats * getTeamTierLimitPerSeat()
|
||||
}
|
||||
if (subscription.plan === 'enterprise') {
|
||||
// For enterprise plans, return the total pooled limit (seats * cost per seat)
|
||||
// This becomes the user's individual limit representing their share of the enterprise pool
|
||||
let perSeatPrice = getEnterpriseTierLimitPerSeat()
|
||||
if (subscription.metadata?.perSeatPrice) {
|
||||
const parsed = Number.parseFloat(String(subscription.metadata.perSeatPrice))
|
||||
if (parsed > 0 && !Number.isNaN(parsed)) {
|
||||
perSeatPrice = parsed
|
||||
}
|
||||
}
|
||||
return seats * perSeatPrice
|
||||
}
|
||||
|
||||
return getFreeTierLimit()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,5 +151,5 @@ export function canEditUsageLimit(subscription: any): boolean {
|
||||
* @returns The minimum allowed usage limit in dollars
|
||||
*/
|
||||
export function getMinimumUsageLimit(subscription: any): number {
|
||||
return calculateDefaultUsageLimit(subscription)
|
||||
return getPerUserMinimumLimit(subscription)
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member, organization, session, subscription, user } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('TeamManagement')
|
||||
|
||||
type SubscriptionData = {
|
||||
id: string
|
||||
plan: string
|
||||
referenceId: string
|
||||
status: string
|
||||
seats?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-create organization for team plan subscriptions
|
||||
*/
|
||||
export async function handleTeamPlanOrganization(
|
||||
subscriptionData: SubscriptionData
|
||||
): Promise<void> {
|
||||
if (subscriptionData.plan !== 'team') return
|
||||
|
||||
try {
|
||||
// For team subscriptions, referenceId should be the user ID initially
|
||||
// But if the organization has already been created, it might be the org ID
|
||||
let userId: string = subscriptionData.referenceId
|
||||
let currentUser: any = null
|
||||
|
||||
// First try to get user directly (most common case)
|
||||
const users = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, subscriptionData.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (users.length > 0) {
|
||||
currentUser = users[0]
|
||||
userId = currentUser.id
|
||||
} else {
|
||||
// If referenceId is not a user ID, it might be an organization ID
|
||||
// In that case, the organization already exists, so we should skip
|
||||
const existingOrg = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, subscriptionData.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (existingOrg.length > 0) {
|
||||
logger.info('Organization already exists for team subscription, skipping creation', {
|
||||
organizationId: subscriptionData.referenceId,
|
||||
subscriptionId: subscriptionData.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.warn('User not found for team subscription and no existing organization', {
|
||||
referenceId: subscriptionData.referenceId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user already has an organization membership
|
||||
const existingMember = await db.select().from(member).where(eq(member.userId, userId)).limit(1)
|
||||
|
||||
if (existingMember.length > 0) {
|
||||
logger.info('User already has organization membership, skipping auto-creation', {
|
||||
userId,
|
||||
existingOrgId: existingMember[0].organizationId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const orgName = `${currentUser.name || 'User'}'s Team`
|
||||
const orgSlug = `${currentUser.name?.toLowerCase().replace(/\s+/g, '-') || 'team'}-${Date.now()}`
|
||||
|
||||
// Create organization directly in database
|
||||
const orgId = `org_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
|
||||
|
||||
const [createdOrg] = await db
|
||||
.insert(organization)
|
||||
.values({
|
||||
id: orgId,
|
||||
name: orgName,
|
||||
slug: orgSlug,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
if (!createdOrg) {
|
||||
throw new Error('Failed to create organization in database')
|
||||
}
|
||||
|
||||
// Add the user as admin of the organization (owner role for full control)
|
||||
await db.insert(member).values({
|
||||
id: `member_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`,
|
||||
userId: currentUser.id,
|
||||
organizationId: orgId,
|
||||
role: 'owner', // Owner gives full admin privileges
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Update the subscription to reference the organization instead of the user
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ referenceId: orgId })
|
||||
.where(eq(subscription.id, subscriptionData.id))
|
||||
|
||||
// Update the user's session to set the new organization as active
|
||||
await db
|
||||
.update(session)
|
||||
.set({ activeOrganizationId: orgId })
|
||||
.where(eq(session.userId, currentUser.id))
|
||||
|
||||
logger.info('Auto-created organization for team subscription', {
|
||||
organizationId: orgId,
|
||||
userId: currentUser.id,
|
||||
subscriptionId: subscriptionData.id,
|
||||
orgName,
|
||||
userRole: 'owner',
|
||||
})
|
||||
|
||||
// Update subscription object for subsequent logic
|
||||
subscriptionData.referenceId = orgId
|
||||
} catch (error) {
|
||||
logger.error('Failed to auto-create organization for team subscription', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync usage limits for user or organization
|
||||
* Handles the complexity of determining whether to sync for user ID or org members
|
||||
*/
|
||||
export async function syncSubscriptionUsageLimits(
|
||||
subscriptionData: SubscriptionData
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { syncUsageLimitsFromSubscription } = await import('@/lib/billing')
|
||||
|
||||
// For team plans, the referenceId is now an organization ID
|
||||
// We need to sync limits for the organization members
|
||||
if (subscriptionData.plan === 'team') {
|
||||
// Get all members of the organization
|
||||
const orgMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, subscriptionData.referenceId))
|
||||
|
||||
// Sync usage limits for each member
|
||||
for (const orgMember of orgMembers) {
|
||||
await syncUsageLimitsFromSubscription(orgMember.userId)
|
||||
}
|
||||
|
||||
logger.info('Synced usage limits for team organization members', {
|
||||
organizationId: subscriptionData.referenceId,
|
||||
memberCount: orgMembers.length,
|
||||
})
|
||||
} else {
|
||||
// For non-team plans, referenceId is the user ID
|
||||
await syncUsageLimitsFromSubscription(subscriptionData.referenceId)
|
||||
logger.info('Synced usage limits for user', {
|
||||
userId: subscriptionData.referenceId,
|
||||
plan: subscriptionData.plan,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync subscription usage limits', {
|
||||
subscriptionId: subscriptionData.id,
|
||||
referenceId: subscriptionData.referenceId,
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,17 @@
|
||||
* Centralized type definitions for the billing system
|
||||
*/
|
||||
|
||||
export interface SubscriptionFeatures {
|
||||
sharingEnabled: boolean
|
||||
multiplayerEnabled: boolean
|
||||
workspaceCollaborationEnabled: boolean
|
||||
export interface EnterpriseSubscriptionMetadata {
|
||||
plan: 'enterprise'
|
||||
// Custom per-seat pricing (defaults to DEFAULT_ENTERPRISE_TIER_COST_LIMIT)
|
||||
referenceId: string
|
||||
perSeatPrice?: number
|
||||
|
||||
// Maximum allowed seats (defaults to subscription.seats)
|
||||
maxSeats?: number
|
||||
|
||||
// Whether seats are fixed and cannot be changed
|
||||
fixedSeats?: boolean
|
||||
}
|
||||
|
||||
export interface UsageData {
|
||||
@@ -25,7 +32,6 @@ export interface UsageLimitInfo {
|
||||
canEdit: boolean
|
||||
minimumLimit: number
|
||||
plan: string
|
||||
setBy: string | null
|
||||
updatedAt: Date | null
|
||||
}
|
||||
|
||||
@@ -44,7 +50,6 @@ export interface UserSubscriptionState {
|
||||
isEnterprise: boolean
|
||||
isFree: boolean
|
||||
highestPrioritySubscription: any | null
|
||||
features: SubscriptionFeatures
|
||||
hasExceededLimit: boolean
|
||||
planName: string
|
||||
}
|
||||
@@ -54,9 +59,6 @@ export interface SubscriptionPlan {
|
||||
priceId: string
|
||||
limits: {
|
||||
cost: number
|
||||
sharingEnabled: number
|
||||
multiplayerEnabled: number
|
||||
workspaceCollaborationEnabled: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +139,6 @@ export interface SubscriptionAPIResponse {
|
||||
status: string | null
|
||||
seats: number | null
|
||||
metadata: any | null
|
||||
features: SubscriptionFeatures
|
||||
usage: UsageData
|
||||
}
|
||||
|
||||
@@ -190,12 +191,10 @@ export interface UseSubscriptionStateReturn {
|
||||
seats?: number
|
||||
metadata?: any
|
||||
}
|
||||
features: SubscriptionFeatures
|
||||
usage: UsageData
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
refetch: () => Promise<any>
|
||||
hasFeature: (feature: keyof SubscriptionFeatures) => boolean
|
||||
isAtLeastPro: () => boolean
|
||||
isAtLeastTeam: () => boolean
|
||||
canUpgrade: () => boolean
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, count, eq } from 'drizzle-orm'
|
||||
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
||||
import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types'
|
||||
import { quickValidateEmail } from '@/lib/email/validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
@@ -71,11 +72,11 @@ export async function validateSeatAvailability(
|
||||
// For enterprise plans, check metadata for custom seat allowances
|
||||
if (subscription.plan === 'enterprise' && subscription.metadata) {
|
||||
try {
|
||||
const metadata =
|
||||
const metadata: EnterpriseSubscriptionMetadata =
|
||||
typeof subscription.metadata === 'string'
|
||||
? JSON.parse(subscription.metadata)
|
||||
: subscription.metadata
|
||||
if (metadata.maxSeats) {
|
||||
if (metadata.maxSeats && typeof metadata.maxSeats === 'number') {
|
||||
maxSeats = metadata.maxSeats
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -166,11 +167,11 @@ export async function getOrganizationSeatInfo(
|
||||
|
||||
if (subscription.plan === 'enterprise' && subscription.metadata) {
|
||||
try {
|
||||
const metadata =
|
||||
const metadata: EnterpriseSubscriptionMetadata =
|
||||
typeof subscription.metadata === 'string'
|
||||
? JSON.parse(subscription.metadata)
|
||||
: subscription.metadata
|
||||
if (metadata.maxSeats) {
|
||||
if (metadata.maxSeats && typeof metadata.maxSeats === 'number') {
|
||||
maxSeats = metadata.maxSeats
|
||||
}
|
||||
// Enterprise plans might have fixed seat counts
|
||||
|
||||
337
apps/sim/lib/billing/webhooks/invoices.ts
Normal file
337
apps/sim/lib/billing/webhooks/invoices.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { member, subscription as subscriptionTable, userStats } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('StripeInvoiceWebhooks')
|
||||
|
||||
async function resetUsageForSubscription(sub: { plan: string | null; referenceId: string }) {
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
const membersRows = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
|
||||
for (const m of membersRows) {
|
||||
const currentStats = await db
|
||||
.select({ current: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, m.userId))
|
||||
.limit(1)
|
||||
if (currentStats.length > 0) {
|
||||
const current = currentStats[0].current || '0'
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ lastPeriodCost: current, currentPeriodCost: '0' })
|
||||
.where(eq(userStats.userId, m.userId))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const currentStats = await db
|
||||
.select({ current: userStats.currentPeriodCost })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
.limit(1)
|
||||
if (currentStats.length > 0) {
|
||||
const current = currentStats[0].current || '0'
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ lastPeriodCost: current, currentPeriodCost: '0' })
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice payment succeeded webhook
|
||||
* We unblock any previously blocked users for this subscription.
|
||||
*/
|
||||
export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
||||
try {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
|
||||
if (!invoice.subscription) return
|
||||
const stripeSubscriptionId = String(invoice.subscription)
|
||||
const records = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId))
|
||||
.limit(1)
|
||||
|
||||
if (records.length === 0) return
|
||||
const sub = records[0]
|
||||
|
||||
// Only reset usage here if the tenant was previously blocked; otherwise invoice.created already reset it
|
||||
let wasBlocked = false
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
const membersRows = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
for (const m of membersRows) {
|
||||
const row = await db
|
||||
.select({ blocked: userStats.billingBlocked })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, m.userId))
|
||||
.limit(1)
|
||||
if (row.length > 0 && row[0].blocked) {
|
||||
wasBlocked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const row = await db
|
||||
.select({ blocked: userStats.billingBlocked })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
.limit(1)
|
||||
wasBlocked = row.length > 0 ? !!row[0].blocked : false
|
||||
}
|
||||
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
for (const m of members) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false })
|
||||
.where(eq(userStats.userId, m.userId))
|
||||
}
|
||||
} else {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false })
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
}
|
||||
|
||||
if (wasBlocked) {
|
||||
await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle invoice payment succeeded', { eventId: event.id, error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice payment failed webhook
|
||||
* This is triggered when a user's payment fails for a usage billing invoice
|
||||
*/
|
||||
export async function handleInvoicePaymentFailed(event: Stripe.Event) {
|
||||
try {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
|
||||
// Check if this is an overage billing invoice
|
||||
if (invoice.metadata?.type !== 'overage_billing') {
|
||||
logger.info('Ignoring non-overage billing invoice payment failure', { invoiceId: invoice.id })
|
||||
return
|
||||
}
|
||||
|
||||
const customerId = invoice.customer as string
|
||||
const failedAmount = invoice.amount_due / 100 // Convert from cents to dollars
|
||||
const billingPeriod = invoice.metadata?.billingPeriod || 'unknown'
|
||||
const attemptCount = invoice.attempt_count || 1
|
||||
|
||||
logger.warn('Overage billing invoice payment failed', {
|
||||
invoiceId: invoice.id,
|
||||
customerId,
|
||||
failedAmount,
|
||||
billingPeriod,
|
||||
attemptCount,
|
||||
customerEmail: invoice.customer_email,
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
})
|
||||
|
||||
// Implement dunning management logic here
|
||||
// For example: suspend service after multiple failures, notify admins, etc.
|
||||
if (attemptCount >= 1) {
|
||||
logger.error('Multiple payment failures for overage billing', {
|
||||
invoiceId: invoice.id,
|
||||
customerId,
|
||||
attemptCount,
|
||||
})
|
||||
// Block all users under this customer (org members or individual)
|
||||
const stripeSubscriptionId = String(invoice.subscription || '')
|
||||
if (stripeSubscriptionId) {
|
||||
const records = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId))
|
||||
.limit(1)
|
||||
|
||||
if (records.length > 0) {
|
||||
const sub = records[0]
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
for (const m of members) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true })
|
||||
.where(eq(userStats.userId, m.userId))
|
||||
}
|
||||
} else {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true })
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle invoice payment failed', {
|
||||
eventId: event.id,
|
||||
error,
|
||||
})
|
||||
throw error // Re-throw to signal webhook failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle base invoice finalized → create a separate overage-only invoice
|
||||
*/
|
||||
export async function handleInvoiceFinalized(event: Stripe.Event) {
|
||||
try {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
// Only run for subscription renewal invoices (cycle boundary)
|
||||
if (!invoice.subscription) return
|
||||
if (invoice.billing_reason && invoice.billing_reason !== 'subscription_cycle') return
|
||||
|
||||
const stripeSubscriptionId = String(invoice.subscription)
|
||||
const records = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId))
|
||||
.limit(1)
|
||||
|
||||
if (records.length === 0) return
|
||||
const sub = records[0]
|
||||
|
||||
const stripe = requireStripeClient()
|
||||
const periodEnd =
|
||||
invoice.lines?.data?.[0]?.period?.end || invoice.period_end || Math.floor(Date.now() / 1000)
|
||||
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
|
||||
|
||||
// Compute overage
|
||||
let totalOverage = 0
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
|
||||
let totalTeamUsage = 0
|
||||
for (const m of members) {
|
||||
const usage = await getUserUsageData(m.userId)
|
||||
totalTeamUsage += usage.currentUsage
|
||||
}
|
||||
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(sub.plan, sub)
|
||||
const baseSubscriptionAmount = (sub.seats || 1) * basePrice
|
||||
totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount)
|
||||
} else {
|
||||
const usage = await getUserUsageData(sub.referenceId)
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(sub.plan, sub)
|
||||
totalOverage = Math.max(0, usage.currentUsage - basePrice)
|
||||
}
|
||||
|
||||
// Always reset usage at cycle end, regardless of whether overage > 0
|
||||
await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })
|
||||
|
||||
if (totalOverage <= 0) return
|
||||
|
||||
const customerId = String(invoice.customer)
|
||||
const cents = Math.round(totalOverage * 100)
|
||||
const itemIdemKey = `overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
||||
const invoiceIdemKey = `overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
|
||||
|
||||
// Inherit billing settings from the Stripe subscription/customer for autopay
|
||||
const getPaymentMethodId = (
|
||||
pm: string | Stripe.PaymentMethod | null | undefined
|
||||
): string | undefined => (typeof pm === 'string' ? pm : pm?.id)
|
||||
|
||||
let collectionMethod: 'charge_automatically' | 'send_invoice' = 'charge_automatically'
|
||||
let defaultPaymentMethod: string | undefined
|
||||
try {
|
||||
const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId)
|
||||
if (stripeSub.collection_method === 'send_invoice') {
|
||||
collectionMethod = 'send_invoice'
|
||||
}
|
||||
const subDpm = getPaymentMethodId(stripeSub.default_payment_method)
|
||||
if (subDpm) {
|
||||
defaultPaymentMethod = subDpm
|
||||
} else if (collectionMethod === 'charge_automatically') {
|
||||
const custObj = await stripe.customers.retrieve(customerId)
|
||||
if (custObj && !('deleted' in custObj)) {
|
||||
const cust = custObj as Stripe.Customer
|
||||
const custDpm = getPaymentMethodId(cust.invoice_settings?.default_payment_method)
|
||||
if (custDpm) defaultPaymentMethod = custDpm
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to retrieve subscription or customer', { error: e })
|
||||
}
|
||||
|
||||
// Create a draft invoice first so we can attach the item directly
|
||||
const overageInvoice = await stripe.invoices.create(
|
||||
{
|
||||
customer: customerId,
|
||||
collection_method: collectionMethod,
|
||||
auto_advance: false,
|
||||
...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}),
|
||||
metadata: {
|
||||
type: 'overage_billing',
|
||||
billingPeriod,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
},
|
||||
},
|
||||
{ idempotencyKey: invoiceIdemKey }
|
||||
)
|
||||
|
||||
// Attach the item to this invoice
|
||||
await stripe.invoiceItems.create(
|
||||
{
|
||||
customer: customerId,
|
||||
invoice: overageInvoice.id,
|
||||
amount: cents,
|
||||
currency: 'usd',
|
||||
description: `Usage Based Overage – ${billingPeriod}`,
|
||||
metadata: {
|
||||
type: 'overage_billing',
|
||||
billingPeriod,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
},
|
||||
},
|
||||
{ idempotencyKey: itemIdemKey }
|
||||
)
|
||||
|
||||
// Finalize to trigger autopay (if charge_automatically and a PM is present)
|
||||
const finalized = await stripe.invoices.finalizeInvoice(overageInvoice.id)
|
||||
// Some manual invoices may remain open after finalize; ensure we pay immediately when possible
|
||||
if (collectionMethod === 'charge_automatically' && finalized.status === 'open') {
|
||||
try {
|
||||
await stripe.invoices.pay(finalized.id, {
|
||||
payment_method: defaultPaymentMethod,
|
||||
})
|
||||
} catch (payError) {
|
||||
logger.error('Failed to auto-pay overage invoice', {
|
||||
error: payError,
|
||||
invoiceId: finalized.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle invoice finalized', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import {
|
||||
resetOrganizationBillingPeriod,
|
||||
resetUserBillingPeriod,
|
||||
} from '@/lib/billing/core/billing-periods'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { subscription as subscriptionTable } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('StripeInvoiceWebhooks')
|
||||
|
||||
/**
|
||||
* Handle invoice payment succeeded webhook
|
||||
* This is triggered when a user successfully pays a usage billing invoice
|
||||
*/
|
||||
export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
||||
try {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
|
||||
// Case 1: Overage invoices (metadata.type === 'overage_billing')
|
||||
if (invoice.metadata?.type === 'overage_billing') {
|
||||
const customerId = invoice.customer as string
|
||||
const chargedAmount = invoice.amount_paid / 100
|
||||
const billingPeriod = invoice.metadata?.billingPeriod || 'unknown'
|
||||
|
||||
logger.info('Overage billing invoice payment succeeded', {
|
||||
invoiceId: invoice.id,
|
||||
customerId,
|
||||
chargedAmount,
|
||||
billingPeriod,
|
||||
customerEmail: invoice.customer_email,
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Case 2: Subscription renewal invoice paid (primary period rollover)
|
||||
// Only reset on successful payment to avoid granting a new period while in dunning
|
||||
if (invoice.subscription) {
|
||||
// Filter to subscription-cycle renewals; ignore updates/off-cycle charges
|
||||
const reason = invoice.billing_reason
|
||||
const isCycle = reason === 'subscription_cycle'
|
||||
if (!isCycle) {
|
||||
logger.info('Ignoring non-cycle subscription invoice on payment_succeeded', {
|
||||
invoiceId: invoice.id,
|
||||
billingReason: reason,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const stripeSubscriptionId = String(invoice.subscription)
|
||||
const records = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId))
|
||||
.limit(1)
|
||||
|
||||
if (records.length === 0) {
|
||||
logger.warn('No matching internal subscription for paid Stripe invoice', {
|
||||
invoiceId: invoice.id,
|
||||
stripeSubscriptionId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const sub = records[0]
|
||||
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
await resetOrganizationBillingPeriod(sub.referenceId)
|
||||
logger.info('Reset organization billing period on subscription invoice payment', {
|
||||
invoiceId: invoice.id,
|
||||
organizationId: sub.referenceId,
|
||||
plan: sub.plan,
|
||||
})
|
||||
} else {
|
||||
await resetUserBillingPeriod(sub.referenceId)
|
||||
logger.info('Reset user billing period on subscription invoice payment', {
|
||||
invoiceId: invoice.id,
|
||||
userId: sub.referenceId,
|
||||
plan: sub.plan,
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Ignoring non-subscription invoice payment', { invoiceId: invoice.id })
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle invoice payment succeeded', {
|
||||
eventId: event.id,
|
||||
error,
|
||||
})
|
||||
throw error // Re-throw to signal webhook failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice payment failed webhook
|
||||
* This is triggered when a user's payment fails for a usage billing invoice
|
||||
*/
|
||||
export async function handleInvoicePaymentFailed(event: Stripe.Event) {
|
||||
try {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
|
||||
// Check if this is an overage billing invoice
|
||||
if (invoice.metadata?.type !== 'overage_billing') {
|
||||
logger.info('Ignoring non-overage billing invoice payment failure', { invoiceId: invoice.id })
|
||||
return
|
||||
}
|
||||
|
||||
const customerId = invoice.customer as string
|
||||
const failedAmount = invoice.amount_due / 100 // Convert from cents to dollars
|
||||
const billingPeriod = invoice.metadata?.billingPeriod || 'unknown'
|
||||
const attemptCount = invoice.attempt_count || 1
|
||||
|
||||
logger.warn('Overage billing invoice payment failed', {
|
||||
invoiceId: invoice.id,
|
||||
customerId,
|
||||
failedAmount,
|
||||
billingPeriod,
|
||||
attemptCount,
|
||||
customerEmail: invoice.customer_email,
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
})
|
||||
|
||||
// Implement dunning management logic here
|
||||
// For example: suspend service after multiple failures, notify admins, etc.
|
||||
if (attemptCount >= 3) {
|
||||
logger.error('Multiple payment failures for overage billing', {
|
||||
invoiceId: invoice.id,
|
||||
customerId,
|
||||
attemptCount,
|
||||
})
|
||||
|
||||
// Could implement service suspension here
|
||||
// await suspendUserService(customerId)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle invoice payment failed', {
|
||||
eventId: event.id,
|
||||
error,
|
||||
})
|
||||
throw error // Re-throw to signal webhook failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice finalized webhook
|
||||
* This is triggered when a usage billing invoice is finalized and ready for payment
|
||||
*/
|
||||
export async function handleInvoiceFinalized(event: Stripe.Event) {
|
||||
try {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
// Do not reset usage on finalized; wait for payment success to avoid granting new period during dunning
|
||||
if (invoice.metadata?.type === 'overage_billing') {
|
||||
const customerId = invoice.customer as string
|
||||
const invoiceAmount = invoice.amount_due / 100
|
||||
const billingPeriod = invoice.metadata?.billingPeriod || 'unknown'
|
||||
logger.info('Overage billing invoice finalized', {
|
||||
invoiceId: invoice.id,
|
||||
customerId,
|
||||
invoiceAmount,
|
||||
billingPeriod,
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.info('Ignoring subscription invoice finalization; will act on payment_succeeded', {
|
||||
invoiceId: invoice.id,
|
||||
billingReason: invoice.billing_reason,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle invoice finalized', {
|
||||
eventId: event.id,
|
||||
error,
|
||||
})
|
||||
throw error // Re-throw to signal webhook failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main webhook handler for all invoice-related events
|
||||
*/
|
||||
export async function handleInvoiceWebhook(event: Stripe.Event) {
|
||||
switch (event.type) {
|
||||
case 'invoice.payment_succeeded':
|
||||
await handleInvoicePaymentSucceeded(event)
|
||||
break
|
||||
|
||||
case 'invoice.payment_failed':
|
||||
await handleInvoicePaymentFailed(event)
|
||||
break
|
||||
|
||||
case 'invoice.finalized':
|
||||
await handleInvoiceFinalized(event)
|
||||
break
|
||||
|
||||
default:
|
||||
logger.info('Unhandled invoice webhook event', { eventType: event.type })
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ export const env = createEnv({
|
||||
|
||||
// Payment & Billing
|
||||
STRIPE_SECRET_KEY: z.string().min(1).optional(), // Stripe secret key for payment processing
|
||||
STRIPE_BILLING_WEBHOOK_SECRET: z.string().min(1).optional(), // Webhook secret for billing events
|
||||
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), // General Stripe webhook secret
|
||||
STRIPE_FREE_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for free tier
|
||||
FREE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for free tier users
|
||||
|
||||
@@ -14,18 +14,4 @@ describe('ExecutionLogger', () => {
|
||||
expect(logger).toBeInstanceOf(ExecutionLogger)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTriggerPrefix', () => {
|
||||
test('should return correct prefixes for trigger types', () => {
|
||||
// Access the private method for testing
|
||||
const getTriggerPrefix = (logger as any).getTriggerPrefix.bind(logger)
|
||||
|
||||
expect(getTriggerPrefix('api')).toBe('API')
|
||||
expect(getTriggerPrefix('webhook')).toBe('Webhook')
|
||||
expect(getTriggerPrefix('schedule')).toBe('Scheduled')
|
||||
expect(getTriggerPrefix('manual')).toBe('Manual')
|
||||
expect(getTriggerPrefix('chat')).toBe('Chat')
|
||||
expect(getTriggerPrefix('unknown' as any)).toBe('Unknown')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -272,32 +272,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
// Check if user stats record exists
|
||||
const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
|
||||
if (userStatsRecords.length === 0) {
|
||||
// Create new user stats record with trigger-specific counts
|
||||
const triggerCounts = this.getTriggerCounts(trigger)
|
||||
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: userId,
|
||||
totalManualExecutions: triggerCounts.manual,
|
||||
totalApiCalls: triggerCounts.api,
|
||||
totalWebhookTriggers: triggerCounts.webhook,
|
||||
totalScheduledExecutions: triggerCounts.schedule,
|
||||
totalChatExecutions: triggerCounts.chat,
|
||||
totalTokensUsed: costSummary.totalTokens,
|
||||
totalCost: costToStore.toString(),
|
||||
currentPeriodCost: costToStore.toString(), // Initialize current period usage
|
||||
lastActive: new Date(),
|
||||
})
|
||||
|
||||
logger.debug('Created new user stats record with cost data', {
|
||||
userId,
|
||||
trigger,
|
||||
totalCost: costToStore,
|
||||
totalTokens: costSummary.totalTokens,
|
||||
})
|
||||
} else {
|
||||
// Update existing user stats record with trigger-specific increments
|
||||
if (userStatsRecords.length > 0) {
|
||||
// Update user stats record with trigger-specific increments
|
||||
const updateFields: any = {
|
||||
totalTokensUsed: sql`total_tokens_used + ${costSummary.totalTokens}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
@@ -326,12 +302,18 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
|
||||
await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
|
||||
|
||||
logger.debug('Updated existing user stats record with cost data', {
|
||||
logger.debug('Updated user stats record with cost data', {
|
||||
userId,
|
||||
trigger,
|
||||
addedCost: costToStore,
|
||||
addedTokens: costSummary.totalTokens,
|
||||
})
|
||||
} else {
|
||||
logger.error('User stats record not found - should be created during onboarding', {
|
||||
userId,
|
||||
trigger,
|
||||
})
|
||||
return // Skip cost tracking if user stats doesn't exist
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating user stats with cost information', {
|
||||
@@ -343,54 +325,6 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trigger counts for new user stats records
|
||||
*/
|
||||
private getTriggerCounts(trigger: ExecutionTrigger['type']): {
|
||||
manual: number
|
||||
api: number
|
||||
webhook: number
|
||||
schedule: number
|
||||
chat: number
|
||||
} {
|
||||
const counts = { manual: 0, api: 0, webhook: 0, schedule: 0, chat: 0 }
|
||||
switch (trigger) {
|
||||
case 'manual':
|
||||
counts.manual = 1
|
||||
break
|
||||
case 'api':
|
||||
counts.api = 1
|
||||
break
|
||||
case 'webhook':
|
||||
counts.webhook = 1
|
||||
break
|
||||
case 'schedule':
|
||||
counts.schedule = 1
|
||||
break
|
||||
case 'chat':
|
||||
counts.chat = 1
|
||||
break
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
private getTriggerPrefix(triggerType: ExecutionTrigger['type']): string {
|
||||
switch (triggerType) {
|
||||
case 'api':
|
||||
return 'API'
|
||||
case 'webhook':
|
||||
return 'Webhook'
|
||||
case 'schedule':
|
||||
return 'Scheduled'
|
||||
case 'manual':
|
||||
return 'Manual'
|
||||
case 'chat':
|
||||
return 'Chat'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file references from execution trace spans and final output
|
||||
*/
|
||||
|
||||
175
apps/sim/lib/subscription/upgrade.ts
Normal file
175
apps/sim/lib/subscription/upgrade.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useCallback } from 'react'
|
||||
import { client, useSession, useSubscription } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
|
||||
const logger = createLogger('SubscriptionUpgrade')
|
||||
|
||||
type TargetPlan = 'pro' | 'team'
|
||||
|
||||
const CONSTANTS = {
|
||||
INITIAL_TEAM_SEATS: 1,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Handles organization creation for team plans and proper referenceId management
|
||||
*/
|
||||
export function useSubscriptionUpgrade() {
|
||||
const { data: session } = useSession()
|
||||
const betterAuthSubscription = useSubscription()
|
||||
const { loadData: loadOrganizationData } = useOrganizationStore()
|
||||
|
||||
const handleUpgrade = useCallback(
|
||||
async (targetPlan: TargetPlan) => {
|
||||
const userId = session?.user?.id
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated')
|
||||
}
|
||||
|
||||
let currentSubscriptionId: string | undefined
|
||||
try {
|
||||
const listResult = await client.subscription.list()
|
||||
const activePersonalSub = listResult.data?.find(
|
||||
(sub: any) => sub.status === 'active' && sub.referenceId === userId
|
||||
)
|
||||
currentSubscriptionId = activePersonalSub?.id
|
||||
} catch (_e) {
|
||||
currentSubscriptionId = undefined
|
||||
}
|
||||
|
||||
let referenceId = userId
|
||||
|
||||
// For team plans, create organization first and use its ID as referenceId
|
||||
if (targetPlan === 'team') {
|
||||
try {
|
||||
logger.info('Creating organization for team plan upgrade', {
|
||||
userId,
|
||||
})
|
||||
|
||||
const response = await fetch('/api/organizations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create organization: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
logger.info('Organization API response', {
|
||||
result,
|
||||
success: result.success,
|
||||
organizationId: result.organizationId,
|
||||
})
|
||||
|
||||
if (!result.success || !result.organizationId) {
|
||||
throw new Error('Failed to create organization for team plan')
|
||||
}
|
||||
|
||||
referenceId = result.organizationId
|
||||
|
||||
// Set the organization as active so Better Auth recognizes it
|
||||
try {
|
||||
await client.organization.setActive({ organizationId: result.organizationId })
|
||||
|
||||
logger.info('Set organization as active and updated referenceId', {
|
||||
organizationId: result.organizationId,
|
||||
oldReferenceId: userId,
|
||||
newReferenceId: referenceId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Failed to set organization as active, but proceeding with upgrade', {
|
||||
organizationId: result.organizationId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
// Continue with upgrade even if setting active fails
|
||||
}
|
||||
|
||||
if (currentSubscriptionId) {
|
||||
const transferResponse = await fetch(
|
||||
`/api/users/me/subscription/${currentSubscriptionId}/transfer`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ organizationId: referenceId }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!transferResponse.ok) {
|
||||
const text = await transferResponse.text()
|
||||
throw new Error(text || 'Failed to transfer subscription to organization')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create organization for team plan', error)
|
||||
throw new Error('Failed to create team workspace. Please try again or contact support.')
|
||||
}
|
||||
}
|
||||
|
||||
const currentUrl = `${window.location.origin}${window.location.pathname}`
|
||||
|
||||
try {
|
||||
const upgradeParams = {
|
||||
plan: targetPlan,
|
||||
referenceId,
|
||||
successUrl: currentUrl,
|
||||
cancelUrl: currentUrl,
|
||||
...(targetPlan === 'team' && { seats: CONSTANTS.INITIAL_TEAM_SEATS }),
|
||||
} as const
|
||||
|
||||
// Add subscriptionId for existing subscriptions to ensure proper plan switching
|
||||
const finalParams = currentSubscriptionId
|
||||
? { ...upgradeParams, subscriptionId: currentSubscriptionId }
|
||||
: upgradeParams
|
||||
|
||||
logger.info(
|
||||
currentSubscriptionId ? 'Upgrading existing subscription' : 'Creating new subscription',
|
||||
{
|
||||
targetPlan,
|
||||
currentSubscriptionId,
|
||||
referenceId,
|
||||
}
|
||||
)
|
||||
|
||||
await betterAuthSubscription.upgrade(finalParams)
|
||||
|
||||
// For team plans, refresh organization data to ensure UI updates
|
||||
if (targetPlan === 'team') {
|
||||
try {
|
||||
await loadOrganizationData()
|
||||
logger.info('Refreshed organization data after team upgrade')
|
||||
} catch (error) {
|
||||
logger.warn('Failed to refresh organization data after upgrade', error)
|
||||
// Don't fail the entire upgrade if data refresh fails
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Subscription upgrade completed successfully', {
|
||||
targetPlan,
|
||||
referenceId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to initiate subscription upgrade:', error)
|
||||
|
||||
// Log detailed error information for debugging
|
||||
if (error instanceof Error) {
|
||||
console.error('Detailed error:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
cause: error.cause,
|
||||
})
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to upgrade subscription: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
},
|
||||
[session?.user?.id, betterAuthSubscription, loadOrganizationData]
|
||||
)
|
||||
|
||||
return { handleUpgrade }
|
||||
}
|
||||
@@ -59,29 +59,19 @@ export async function updateWorkflowRunCounts(workflowId: string, runs = 1) {
|
||||
.limit(1)
|
||||
|
||||
if (userStatsRecord.length === 0) {
|
||||
// Create new record
|
||||
await db.insert(userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
console.warn('User stats record not found - should be created during onboarding', {
|
||||
userId: workflow.userId,
|
||||
totalManualExecutions: runs,
|
||||
totalApiCalls: 0,
|
||||
totalWebhookTriggers: 0,
|
||||
totalScheduledExecutions: 0,
|
||||
totalChatExecutions: 0,
|
||||
totalTokensUsed: 0,
|
||||
totalCost: '0.00',
|
||||
})
|
||||
return // Skip stats update if record doesn't exist
|
||||
}
|
||||
// Update existing record
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
totalManualExecutions: userStatsRecord[0].totalManualExecutions + runs,
|
||||
lastActive: new Date(),
|
||||
})
|
||||
} else {
|
||||
// Update existing record
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
totalManualExecutions: userStatsRecord[0].totalManualExecutions + runs,
|
||||
lastActive: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, workflow.userId))
|
||||
}
|
||||
.where(eq(userStats.userId, workflow.userId))
|
||||
}
|
||||
|
||||
return { success: true, runsAdded: runs }
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
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', 10) // $10 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)
|
||||
})
|
||||
@@ -24,7 +24,6 @@ export async function authenticateSocket(socket: AuthenticatedSocket, next: any)
|
||||
hasToken: !!token,
|
||||
origin,
|
||||
referer,
|
||||
allHeaders: Object.keys(socket.handshake.headers),
|
||||
})
|
||||
|
||||
if (!token) {
|
||||
|
||||
@@ -167,44 +167,6 @@ export const useOrganizationStore = create<OrganizationStore>()(
|
||||
lastSubscriptionFetched: Date.now(),
|
||||
})
|
||||
} else {
|
||||
// Check billing endpoint for enterprise subscriptions
|
||||
const { hasEnterprisePlan } = get()
|
||||
if (hasEnterprisePlan) {
|
||||
try {
|
||||
const billingResponse = await fetch('/api/billing?context=user')
|
||||
if (billingResponse.ok) {
|
||||
const billingData = await billingResponse.json()
|
||||
if (
|
||||
billingData.success &&
|
||||
billingData.data.isEnterprise &&
|
||||
billingData.data.status
|
||||
) {
|
||||
const enterpriseSubscription = {
|
||||
id: `subscription_${Date.now()}`,
|
||||
plan: billingData.data.plan,
|
||||
status: billingData.data.status,
|
||||
seats: billingData.data.seats,
|
||||
referenceId: billingData.data.organizationId || 'unknown',
|
||||
}
|
||||
logger.info('Found enterprise subscription from billing data', {
|
||||
plan: enterpriseSubscription.plan,
|
||||
seats: enterpriseSubscription.seats,
|
||||
})
|
||||
set({
|
||||
subscriptionData: enterpriseSubscription,
|
||||
isLoadingSubscription: false,
|
||||
lastSubscriptionFetched: Date.now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching enterprise subscription from billing endpoint', {
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('No active subscription found for organization', { orgId })
|
||||
set({
|
||||
subscriptionData: null,
|
||||
@@ -221,13 +183,14 @@ export const useOrganizationStore = create<OrganizationStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
loadOrganizationBillingData: async (organizationId: string) => {
|
||||
loadOrganizationBillingData: async (organizationId: string, force?: boolean) => {
|
||||
const state = get()
|
||||
|
||||
if (
|
||||
state.organizationBillingData &&
|
||||
state.lastOrgBillingFetched &&
|
||||
Date.now() - state.lastOrgBillingFetched < CACHE_DURATION
|
||||
Date.now() - state.lastOrgBillingFetched < CACHE_DURATION &&
|
||||
!force
|
||||
) {
|
||||
logger.debug('Using cached organization billing data')
|
||||
return
|
||||
@@ -331,6 +294,14 @@ export const useOrganizationStore = create<OrganizationStore>()(
|
||||
const fullOrgResponse = await client.organization.getFullOrganization()
|
||||
const updatedOrg = fullOrgResponse.data
|
||||
|
||||
logger.info('Refreshed organization data', {
|
||||
orgId: updatedOrg?.id,
|
||||
members: updatedOrg?.members?.length ?? 0,
|
||||
invitations: updatedOrg?.invitations?.length ?? 0,
|
||||
pendingInvitations:
|
||||
updatedOrg?.invitations?.filter((inv: any) => inv.status === 'pending').length ?? 0,
|
||||
})
|
||||
|
||||
set({ activeOrganization: updatedOrg })
|
||||
|
||||
// Also refresh subscription data
|
||||
@@ -586,37 +557,6 @@ export const useOrganizationStore = create<OrganizationStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
updateMemberUsageLimit: async (userId: string, organizationId: string, newLimit: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/usage-limits?context=member&userId=${userId}&organizationId=${organizationId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ limit: newLimit }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update member usage limit')
|
||||
}
|
||||
|
||||
// Refresh organization billing data
|
||||
await get().loadOrganizationBillingData(organizationId)
|
||||
|
||||
logger.debug('Member usage limit updated successfully', { userId, newLimit })
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update member usage limit'
|
||||
logger.error('Failed to update member usage limit', { error, userId, newLimit })
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
},
|
||||
|
||||
// Seat management
|
||||
addSeats: async (newSeatCount: number) => {
|
||||
const { activeOrganization, subscriptionData } = get()
|
||||
@@ -663,7 +603,7 @@ export const useOrganizationStore = create<OrganizationStore>()(
|
||||
}
|
||||
|
||||
const { used: totalCount } = calculateSeatUsage(activeOrganization)
|
||||
if (totalCount >= newSeatCount) {
|
||||
if (totalCount > newSeatCount) {
|
||||
set({
|
||||
error: `You have ${totalCount} active members/invitations. Please remove members or cancel invitations before reducing seats.`,
|
||||
})
|
||||
|
||||
@@ -80,13 +80,16 @@ export interface OrganizationBillingData {
|
||||
subscriptionStatus: string
|
||||
totalSeats: number
|
||||
usedSeats: number
|
||||
seatsCount: number
|
||||
totalCurrentUsage: number
|
||||
totalUsageLimit: number
|
||||
minimumBillingAmount: number
|
||||
averageUsagePerMember: number
|
||||
billingPeriodStart: string | null
|
||||
billingPeriodEnd: string | null
|
||||
members?: MemberUsageData[]
|
||||
userRole?: string
|
||||
billingBlocked?: boolean
|
||||
}
|
||||
|
||||
export interface OrganizationState {
|
||||
@@ -133,7 +136,7 @@ export interface OrganizationState {
|
||||
export interface OrganizationStore extends OrganizationState {
|
||||
loadData: () => Promise<void>
|
||||
loadOrganizationSubscription: (orgId: string) => Promise<void>
|
||||
loadOrganizationBillingData: (organizationId: string) => Promise<void>
|
||||
loadOrganizationBillingData: (organizationId: string, force?: boolean) => Promise<void>
|
||||
loadUserWorkspaces: (userId?: string) => Promise<void>
|
||||
refreshOrganization: () => Promise<void>
|
||||
|
||||
@@ -146,11 +149,6 @@ export interface OrganizationStore extends OrganizationState {
|
||||
inviteMember: (email: string, workspaceInvitations?: WorkspaceInvitation[]) => Promise<void>
|
||||
removeMember: (memberId: string, shouldReduceSeats?: boolean) => Promise<void>
|
||||
cancelInvitation: (invitationId: string) => Promise<void>
|
||||
updateMemberUsageLimit: (
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
newLimit: number
|
||||
) => Promise<{ success: boolean; error?: string }>
|
||||
|
||||
// Seat management
|
||||
addSeats: (newSeatCount: number) => Promise<void>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type {
|
||||
BillingStatus,
|
||||
SubscriptionData,
|
||||
SubscriptionFeatures,
|
||||
SubscriptionStore,
|
||||
UsageData,
|
||||
UsageLimitData,
|
||||
@@ -15,12 +14,6 @@ const logger = createLogger('SubscriptionStore')
|
||||
|
||||
const CACHE_DURATION = 30 * 1000
|
||||
|
||||
const defaultFeatures: SubscriptionFeatures = {
|
||||
sharingEnabled: false,
|
||||
multiplayerEnabled: false,
|
||||
workspaceCollaborationEnabled: false,
|
||||
}
|
||||
|
||||
const defaultUsage: UsageData = {
|
||||
current: 0,
|
||||
limit: DEFAULT_FREE_CREDITS,
|
||||
@@ -72,7 +65,7 @@ export const useSubscriptionStore = create<SubscriptionStore>()(
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const data = result.data
|
||||
const data = { ...result.data, billingBlocked: result.data?.billingBlocked ?? false }
|
||||
|
||||
// Transform dates with error handling
|
||||
const transformedData: SubscriptionData = {
|
||||
@@ -110,6 +103,7 @@ export const useSubscriptionStore = create<SubscriptionStore>()(
|
||||
})()
|
||||
: null,
|
||||
},
|
||||
billingBlocked: !!data.billingBlocked,
|
||||
}
|
||||
|
||||
// Debug logging for billing periods
|
||||
@@ -200,53 +194,6 @@ export const useSubscriptionStore = create<SubscriptionStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
cancelSubscription: async () => {
|
||||
const state = get()
|
||||
if (!state.subscriptionData) {
|
||||
logger.error('No subscription data available for cancellation')
|
||||
return { success: false, error: 'No subscription data available' }
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/me/subscription/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to cancel subscription')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
logger.info('Subscription cancelled successfully', {
|
||||
periodEnd: result.data.periodEnd,
|
||||
cancelAtPeriodEnd: result.data.cancelAtPeriodEnd,
|
||||
})
|
||||
|
||||
// Refresh subscription data to reflect cancellation status
|
||||
await get().refresh()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
periodEnd: result.data.periodEnd ? new Date(result.data.periodEnd) : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to cancel subscription'
|
||||
logger.error('Failed to cancel subscription', { error })
|
||||
set({ error: errorMessage })
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
// Force refresh by clearing cache
|
||||
set({ lastFetched: null })
|
||||
@@ -428,16 +375,14 @@ export const useSubscriptionStore = create<SubscriptionStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
getFeatures: () => {
|
||||
return get().subscriptionData?.features ?? defaultFeatures
|
||||
},
|
||||
|
||||
getUsage: () => {
|
||||
return get().subscriptionData?.usage ?? defaultUsage
|
||||
},
|
||||
|
||||
getBillingStatus: (): BillingStatus => {
|
||||
const usage = get().getUsage()
|
||||
const blocked = get().subscriptionData?.billingBlocked
|
||||
if (blocked) return 'blocked'
|
||||
if (usage.isExceeded) return 'exceeded'
|
||||
if (usage.isWarning) return 'warning'
|
||||
return 'ok'
|
||||
@@ -460,10 +405,6 @@ export const useSubscriptionStore = create<SubscriptionStore>()(
|
||||
return Math.max(0, diffDays)
|
||||
},
|
||||
|
||||
hasFeature: (feature: keyof SubscriptionFeatures) => {
|
||||
return get().getFeatures()[feature] ?? false
|
||||
},
|
||||
|
||||
isAtLeastPro: () => {
|
||||
const status = get().getSubscriptionStatus()
|
||||
return status.isPro || status.isTeam || status.isEnterprise
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
export interface SubscriptionFeatures {
|
||||
sharingEnabled: boolean
|
||||
multiplayerEnabled: boolean
|
||||
workspaceCollaborationEnabled: boolean
|
||||
}
|
||||
|
||||
export interface UsageData {
|
||||
current: number
|
||||
limit: number
|
||||
@@ -35,11 +29,11 @@ export interface SubscriptionData {
|
||||
metadata: any | null
|
||||
stripeSubscriptionId: string | null
|
||||
periodEnd: Date | null
|
||||
features: SubscriptionFeatures
|
||||
usage: UsageData
|
||||
billingBlocked?: boolean
|
||||
}
|
||||
|
||||
export type BillingStatus = 'unknown' | 'ok' | 'warning' | 'exceeded'
|
||||
export type BillingStatus = 'unknown' | 'ok' | 'warning' | 'exceeded' | 'blocked'
|
||||
|
||||
export interface SubscriptionStore {
|
||||
subscriptionData: SubscriptionData | null
|
||||
@@ -54,7 +48,6 @@ export interface SubscriptionStore {
|
||||
usageLimitData: UsageLimitData | null
|
||||
}>
|
||||
updateUsageLimit: (newLimit: number) => Promise<{ success: boolean; error?: string }>
|
||||
cancelSubscription: () => Promise<{ success: boolean; error?: string; periodEnd?: Date }>
|
||||
refresh: () => Promise<void>
|
||||
clearError: () => void
|
||||
reset: () => void
|
||||
@@ -69,12 +62,10 @@ export interface SubscriptionStore {
|
||||
seats: number | null
|
||||
metadata: any | null
|
||||
}
|
||||
getFeatures: () => SubscriptionFeatures
|
||||
getUsage: () => UsageData
|
||||
getBillingStatus: () => BillingStatus
|
||||
getRemainingBudget: () => number
|
||||
getDaysRemainingInPeriod: () => number | null
|
||||
hasFeature: (feature: keyof SubscriptionFeatures) => boolean
|
||||
isAtLeastPro: () => boolean
|
||||
isAtLeastTeam: () => boolean
|
||||
canUpgrade: () => boolean
|
||||
|
||||
6
bun.lock
6
bun.lock
@@ -7,7 +7,9 @@
|
||||
"@linear/sdk": "40.0.0",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@vercel/analytics": "1.5.0",
|
||||
"drizzle-orm": "0.44.5",
|
||||
"geist": "^1.4.2",
|
||||
"pg": "8.16.3",
|
||||
"react-colorful": "5.6.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"socket.io-client": "4.8.1",
|
||||
@@ -1866,7 +1868,7 @@
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="],
|
||||
"drizzle-orm": ["drizzle-orm@0.44.5", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-jBe37K7d8ZSKptdKfakQFdeljtu3P2Cbo7tJoJSVZADzIKOBo9IAJPOmMsH2bZl90bZgh8FQlD8BjxXA/zuBkQ=="],
|
||||
|
||||
"duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
|
||||
|
||||
@@ -3798,6 +3800,8 @@
|
||||
|
||||
"sim/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
|
||||
|
||||
"sim/drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="],
|
||||
|
||||
"sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="],
|
||||
|
||||
"sim/tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
"@linear/sdk": "40.0.0",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@vercel/analytics": "1.5.0",
|
||||
"drizzle-orm": "0.44.5",
|
||||
"geist": "^1.4.2",
|
||||
"pg": "8.16.3",
|
||||
"react-colorful": "5.6.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"socket.io-client": "4.8.1"
|
||||
|
||||
Reference in New Issue
Block a user