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:
Vikhyath Mondreti
2025-08-28 17:00:48 -07:00
committed by GitHub
parent 7cc4574913
commit 56543dafb4
85 changed files with 9033 additions and 5080 deletions

View 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)

View 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 })
}
}

View File

@@ -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,
})
}

View File

@@ -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

View File

@@ -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'],
})
}

View File

@@ -81,7 +81,6 @@ export async function GET(
.select({
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
usageLimitSetBy: userStats.usageLimitSetBy,
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
lastPeriodCost: userStats.lastPeriodCost,
})

View File

@@ -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)

View 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 }
)
}
}

View File

@@ -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

View 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 })
}
}

View File

@@ -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 })

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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` }}
/>
</>
) : (

View File

@@ -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

View File

@@ -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'>

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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}
/>
)
}

View File

@@ -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>
)

View File

@@ -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',

View File

@@ -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>
)

View File

@@ -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)
}
}}

View 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);

File diff suppressed because it is too large Load Diff

View File

@@ -547,6 +547,13 @@
"when": 1756168384465,
"tag": "0078_supreme_madrox",
"breakpoints": true
},
{
"idx": 79,
"version": "7",
"when": 1756246702112,
"tag": "0079_shocking_shriek",
"breakpoints": true
}
]
}

View File

@@ -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(),
})

View File

@@ -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
},

View File

@@ -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,

View File

@@ -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,
})

View 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'
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
})
})
})

View File

@@ -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
}
}

View File

@@ -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())
})
})
})

View File

@@ -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()
}

View File

@@ -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',
}
}
}

View File

@@ -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',
}

View File

@@ -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 })

View File

@@ -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'

View 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
}
}

View 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()
}

View File

@@ -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
}
/**

View File

@@ -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)
})
})
})

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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

View 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
}
}

View File

@@ -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 })
}
}

View File

@@ -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

View File

@@ -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')
})
})
})

View File

@@ -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
*/

View 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 }
}

View File

@@ -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 }

View File

@@ -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)
})

View File

@@ -24,7 +24,6 @@ export async function authenticateSocket(socket: AuthenticatedSocket, next: any)
hasToken: !!token,
origin,
referer,
allHeaders: Object.keys(socket.handshake.headers),
})
if (!token) {

View File

@@ -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.`,
})

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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=="],

View File

@@ -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"