feat(billing): add comprehensive usage-based billing system (#625)

* feat(billing): add comprehensive usage-based billing system

- Complete billing infrastructure with subscription management
- Usage tracking and limits for organizations
- Team management with role-based permissions
- CRON jobs for automated billing and cleanup
- Stripe integration for payments and invoicing
- Email notifications for billing events
- Organization-based workspace management
- API endpoints for billing operations

* fix tests, standardize datetime logic

* add lazy init for stripe client, similar to s3

* cleanup

* ack PR comments

* fixed build

* convert everything to UTC

* add delete subscription functionality using better auth

* fix lint

* fix linter error

* remove invoice emails since it is natively managed via stripe

* fix build

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
This commit is contained in:
Waleed Latif
2025-07-09 22:42:23 -07:00
committed by GitHub
parent 529fd44405
commit e5080febd5
105 changed files with 12479 additions and 5041 deletions

View File

@@ -15,5 +15,3 @@ ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate
# RESEND_API_KEY= # Uncomment and add your key from https://resend.com to send actual emails
# If left commented out, emails will be logged to console instead
# Freestyle API Key (Required for sandboxed code execution for functions/custom-tools)
# FREESTYLE_API_KEY= # Uncomment and add your key from https://docs.freestyle.sh/Getting-Started/run

View File

@@ -1,116 +0,0 @@
'use client'
import { useState } from 'react'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const emailSchema = z.string().email('Please enter a valid email')
export default function WaitlistForm() {
const [email, setEmail] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [status, setStatus] = useState<'idle' | 'success' | 'error' | 'exists' | 'ratelimited'>(
'idle'
)
const [_errorMessage, setErrorMessage] = useState('')
const [_retryAfter, setRetryAfter] = useState<number | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setStatus('idle')
setErrorMessage('')
setRetryAfter(null)
try {
// Validate email
emailSchema.parse(email)
setIsSubmitting(true)
const response = await fetch('/api/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
})
const data = await response.json()
if (!response.ok) {
// Check for rate limiting (429 status)
if (response.status === 429) {
setStatus('ratelimited')
setErrorMessage(data.message || 'Too many attempts. Please try again later.')
setRetryAfter(data.retryAfter || 60)
}
// Check if the error is because the email already exists
else if (response.status === 400 && data.message?.includes('already exists')) {
setStatus('exists')
setErrorMessage('Already on the waitlist')
} else {
setStatus('error')
setErrorMessage(data.message || 'Failed to join waitlist')
}
return
}
setStatus('success')
setEmail('')
} catch (_error) {
setStatus('error')
setErrorMessage('Please try again')
} finally {
setIsSubmitting(false)
}
}
const getButtonText = () => {
if (isSubmitting) return 'Joining...'
if (status === 'success') return 'Joined!'
if (status === 'error') return 'Try again'
if (status === 'exists') return 'Already joined'
if (status === 'ratelimited') return 'Try again later'
return 'Join waitlist'
}
const getButtonStyle = () => {
switch (status) {
case 'success':
return 'bg-green-500 hover:bg-green-600'
case 'error':
return 'bg-red-500 hover:bg-red-600'
case 'exists':
return 'bg-amber-500 hover:bg-amber-600'
case 'ratelimited':
return 'bg-gray-500 hover:bg-gray-600'
default:
return 'bg-white text-black hover:bg-gray-100'
}
}
return (
<form
onSubmit={handleSubmit}
className='mx-auto mt-8 flex max-w-lg flex-col items-center gap-3'
>
<div className='flex w-full gap-3'>
<Input
type='email'
placeholder='you@example.com'
className='h-[49px] flex-1 rounded-md border-white/20 bg-[#020817] text-sm focus:border-white/30 focus:ring-white/30 md:text-md lg:text-[16px]'
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting || status === 'ratelimited'}
/>
<Button
type='submit'
className={`h-[48px] rounded-md px-8 text-sm md:text-md ${getButtonStyle()}`}
disabled={isSubmitting || status === 'ratelimited'}
>
{getButtonText()}
</Button>
</div>
</form>
)
}

View File

@@ -0,0 +1,109 @@
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { processDailyBillingCheck } from '@/lib/billing/core/billing'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('DailyBillingCron')
/**
* Daily billing CRON job endpoint that checks individual billing periods
*/
export async function POST(request: NextRequest) {
try {
const authError = verifyCronAuth(request, 'daily billing check')
if (authError) {
return authError
}
logger.info('Starting daily billing check cron job')
const startTime = Date.now()
// Process overage billing for users and organizations with periods ending today
const result = await processDailyBillingCheck()
const duration = Date.now() - startTime
if (result.success) {
logger.info('Daily billing check completed successfully', {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
duration: `${duration}ms`,
})
return NextResponse.json({
success: true,
summary: {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
duration: `${duration}ms`,
},
})
}
logger.error('Daily billing check completed with errors', {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
errorCount: result.errors.length,
errors: result.errors,
duration: `${duration}ms`,
})
return NextResponse.json(
{
success: false,
summary: {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
errorCount: result.errors.length,
duration: `${duration}ms`,
},
errors: result.errors,
},
{ status: 500 }
)
} catch (error) {
logger.error('Fatal error in monthly billing cron job', { error })
return NextResponse.json(
{
success: false,
error: 'Internal server error during daily billing check',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET endpoint for manual testing and health checks
*/
export async function GET(request: NextRequest) {
try {
const authError = verifyCronAuth(request, 'daily billing check health check')
if (authError) {
return authError
}
return NextResponse.json({
status: 'ready',
message:
'Daily billing check cron job is ready to process users and organizations with periods ending today',
currentDate: new Date().toISOString().split('T')[0],
})
} catch (error) {
logger.error('Error in billing health check', { error })
return NextResponse.json(
{
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,116 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
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'
const logger = createLogger('UnifiedBillingAPI')
/**
* Unified Billing Endpoint
*/
export async function GET(request: NextRequest) {
const session = await getSession()
try {
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const context = searchParams.get('context') || 'user'
const contextId = searchParams.get('id')
// Validate context parameter
if (!['user', 'organization'].includes(context)) {
return NextResponse.json(
{ error: 'Invalid context. Must be "user" or "organization"' },
{ status: 400 }
)
}
// For organization context, require contextId
if (context === 'organization' && !contextId) {
return NextResponse.json(
{ error: 'Organization ID is required when context=organization' },
{ status: 400 }
)
}
let billingData
if (context === 'user') {
// Get user billing (may include organization if they're part of one)
billingData = await getSimplifiedBillingSummary(session.user.id, contextId || undefined)
} else {
// Get user role in organization for permission checks first
const memberRecord = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, contextId!), eq(member.userId, session.user.id)))
.limit(1)
if (memberRecord.length === 0) {
return NextResponse.json(
{ error: 'Access denied - not a member of this organization' },
{ status: 403 }
)
}
// Get organization-specific billing
const rawBillingData = await getOrganizationBillingData(contextId!)
if (!rawBillingData) {
return NextResponse.json(
{ error: 'Organization not found or access denied' },
{ status: 404 }
)
}
// Transform data to match component expectations
billingData = {
organizationId: rawBillingData.organizationId,
organizationName: rawBillingData.organizationName,
subscriptionPlan: rawBillingData.subscriptionPlan,
subscriptionStatus: rawBillingData.subscriptionStatus,
totalSeats: rawBillingData.totalSeats,
usedSeats: rawBillingData.usedSeats,
totalCurrentUsage: rawBillingData.totalCurrentUsage,
totalUsageLimit: rawBillingData.totalUsageLimit,
averageUsagePerMember: rawBillingData.averageUsagePerMember,
billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null,
billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null,
members: rawBillingData.members.map((member) => ({
...member,
joinedAt: member.joinedAt.toISOString(),
lastActive: member.lastActive?.toISOString() || null,
})),
}
const userRole = memberRecord[0].role
return NextResponse.json({
success: true,
context,
data: billingData,
userRole,
})
}
return NextResponse.json({
success: true,
context,
data: billingData,
})
} catch (error) {
logger.error('Failed to get billing data', {
userId: session?.user?.id,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,116 @@
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_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_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

@@ -1,8 +1,7 @@
import { render } from '@react-email/render'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import OTPVerificationEmail from '@/components/emails/otp-verification-email'
import { renderOTPEmail } from '@/components/emails/render-email'
import { sendEmail } from '@/lib/email/mailer'
import { createLogger } from '@/lib/logs/console-logger'
import { getRedisClient, markMessageAsProcessed, releaseLock } from '@/lib/redis'
@@ -158,7 +157,6 @@ export async function POST(
? deployment.allowedEmails
: []
// Check if the email is allowed
const isEmailAllowed =
allowedEmails.includes(email) ||
allowedEmails.some((allowed: string) => {
@@ -176,24 +174,17 @@ export async function POST(
)
}
// Generate OTP
const otp = generateOTP()
// Store OTP in Redis - AWAIT THIS BEFORE RETURNING RESPONSE
await storeOTP(email, deployment.id, otp)
// Create the email
const emailContent = OTPVerificationEmail({
const emailHtml = await renderOTPEmail(
otp,
email,
type: 'chat-access',
chatTitle: deployment.title || 'Chat',
})
'email-verification',
deployment.title || 'Chat'
)
// await the render function
const emailHtml = await render(emailContent)
// MAKE SURE TO AWAIT THE EMAIL SENDING
const emailResult = await sendEmail({
to: email,
subject: `Verification code for ${deployment.title || 'Chat'}`,

View File

@@ -1,6 +1,7 @@
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
import { snapshotService } from '@/lib/logs/snapshot-service'
@@ -18,17 +19,11 @@ const S3_CONFIG = {
region: env.AWS_REGION || '',
}
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization')
if (!env.CRON_SECRET) {
return new NextResponse('Configuration error: Cron secret is not set', { status: 500 })
}
if (!authHeader || authHeader !== `Bearer ${env.CRON_SECRET}`) {
logger.warn('Unauthorized access attempt to logs cleanup endpoint')
return new NextResponse('Unauthorized', { status: 401 })
const authError = verifyCronAuth(request, 'logs cleanup')
if (authError) {
return authError
}
if (!S3_CONFIG.bucket || !S3_CONFIG.region) {

View File

@@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console-logger'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { marketplace } from '@/db/schema'
const logger = createLogger('MarketplaceInfoAPI')
@@ -24,8 +24,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// Fetch marketplace data for the workflow
const marketplaceEntry = await db
.select()
.from(schema.marketplace)
.where(eq(schema.marketplace.workflowId, id))
.from(marketplace)
.where(eq(marketplace.workflowId, id))
.limit(1)
.then((rows) => rows[0])

View File

@@ -4,7 +4,7 @@ import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { marketplace, workflow } from '@/db/schema'
const logger = createLogger('MarketplaceUnpublishAPI')
@@ -34,13 +34,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Get the marketplace entry using the marketplace ID
const marketplaceEntry = await db
.select({
id: schema.marketplace.id,
workflowId: schema.marketplace.workflowId,
authorId: schema.marketplace.authorId,
name: schema.marketplace.name,
id: marketplace.id,
workflowId: marketplace.workflowId,
authorId: marketplace.authorId,
name: marketplace.name,
})
.from(schema.marketplace)
.where(eq(schema.marketplace.id, id))
.from(marketplace)
.where(eq(marketplace.id, id))
.limit(1)
.then((rows) => rows[0])
@@ -60,36 +60,33 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const workflowId = marketplaceEntry.workflowId
// Verify the workflow exists and belongs to the user
const workflow = await db
const workflowEntry = await db
.select({
id: schema.workflow.id,
userId: schema.workflow.userId,
id: workflow.id,
userId: workflow.userId,
})
.from(schema.workflow)
.where(eq(schema.workflow.id, workflowId))
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
.then((rows) => rows[0])
if (!workflow) {
if (!workflowEntry) {
logger.warn(`[${requestId}] Associated workflow not found: ${workflowId}`)
// We'll still delete the marketplace entry even if the workflow is missing
} else if (workflow.userId !== userId) {
} else if (workflowEntry.userId !== userId) {
logger.warn(
`[${requestId}] Workflow ${workflowId} belongs to user ${workflow.userId}, not current user ${userId}`
`[${requestId}] Workflow ${workflowId} belongs to user ${workflowEntry.userId}, not current user ${userId}`
)
return createErrorResponse('You do not have permission to unpublish this workflow', 403)
}
try {
// Delete the marketplace entry - this is the primary action
await db.delete(schema.marketplace).where(eq(schema.marketplace.id, id))
await db.delete(marketplace).where(eq(marketplace.id, id))
// Update the workflow to mark it as unpublished if it exists
if (workflow) {
await db
.update(schema.workflow)
.set({ isPublished: false })
.where(eq(schema.workflow.id, workflowId))
if (workflowEntry) {
await db.update(workflow).set({ isPublished: false }).where(eq(workflow.id, workflowId))
}
logger.info(

View File

@@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { marketplace } from '@/db/schema'
const logger = createLogger('MarketplaceViewAPI')
@@ -22,10 +22,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Find the marketplace entry for this marketplace ID
const marketplaceEntry = await db
.select({
id: schema.marketplace.id,
id: marketplace.id,
})
.from(schema.marketplace)
.where(eq(schema.marketplace.id, id))
.from(marketplace)
.where(eq(marketplace.id, id))
.limit(1)
.then((rows) => rows[0])
@@ -36,11 +36,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Increment the view count for this workflow
await db
.update(schema.marketplace)
.update(marketplace)
.set({
views: sql`${schema.marketplace.views} + 1`,
views: sql`${marketplace.views} + 1`,
})
.where(eq(schema.marketplace.id, id))
.where(eq(marketplace.id, id))
logger.info(`[${requestId}] Incremented view count for marketplace entry: ${id}`)

View File

@@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console-logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { CATEGORIES } from '@/app/workspace/[workspaceId]/marketplace/constants/categories'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { marketplace } from '@/db/schema'
const logger = createLogger('MarketplaceWorkflowsAPI')
@@ -50,39 +50,39 @@ export async function GET(request: NextRequest) {
// Query with state included
marketplaceEntry = await db
.select({
id: schema.marketplace.id,
workflowId: schema.marketplace.workflowId,
name: schema.marketplace.name,
description: schema.marketplace.description,
authorId: schema.marketplace.authorId,
authorName: schema.marketplace.authorName,
state: schema.marketplace.state,
views: schema.marketplace.views,
category: schema.marketplace.category,
createdAt: schema.marketplace.createdAt,
updatedAt: schema.marketplace.updatedAt,
id: marketplace.id,
workflowId: marketplace.workflowId,
name: marketplace.name,
description: marketplace.description,
authorId: marketplace.authorId,
authorName: marketplace.authorName,
state: marketplace.state,
views: marketplace.views,
category: marketplace.category,
createdAt: marketplace.createdAt,
updatedAt: marketplace.updatedAt,
})
.from(schema.marketplace)
.where(eq(schema.marketplace.workflowId, workflowId))
.from(marketplace)
.where(eq(marketplace.workflowId, workflowId))
.limit(1)
.then((rows) => rows[0])
} else {
// Query without state
marketplaceEntry = await db
.select({
id: schema.marketplace.id,
workflowId: schema.marketplace.workflowId,
name: schema.marketplace.name,
description: schema.marketplace.description,
authorId: schema.marketplace.authorId,
authorName: schema.marketplace.authorName,
views: schema.marketplace.views,
category: schema.marketplace.category,
createdAt: schema.marketplace.createdAt,
updatedAt: schema.marketplace.updatedAt,
id: marketplace.id,
workflowId: marketplace.workflowId,
name: marketplace.name,
description: marketplace.description,
authorId: marketplace.authorId,
authorName: marketplace.authorName,
views: marketplace.views,
category: marketplace.category,
createdAt: marketplace.createdAt,
updatedAt: marketplace.updatedAt,
})
.from(schema.marketplace)
.where(eq(schema.marketplace.workflowId, workflowId))
.from(marketplace)
.where(eq(marketplace.workflowId, workflowId))
.limit(1)
.then((rows) => rows[0])
}
@@ -114,39 +114,39 @@ export async function GET(request: NextRequest) {
// Query with state included
marketplaceEntry = await db
.select({
id: schema.marketplace.id,
workflowId: schema.marketplace.workflowId,
name: schema.marketplace.name,
description: schema.marketplace.description,
authorId: schema.marketplace.authorId,
authorName: schema.marketplace.authorName,
state: schema.marketplace.state,
views: schema.marketplace.views,
category: schema.marketplace.category,
createdAt: schema.marketplace.createdAt,
updatedAt: schema.marketplace.updatedAt,
id: marketplace.id,
workflowId: marketplace.workflowId,
name: marketplace.name,
description: marketplace.description,
authorId: marketplace.authorId,
authorName: marketplace.authorName,
state: marketplace.state,
views: marketplace.views,
category: marketplace.category,
createdAt: marketplace.createdAt,
updatedAt: marketplace.updatedAt,
})
.from(schema.marketplace)
.where(eq(schema.marketplace.id, marketplaceId))
.from(marketplace)
.where(eq(marketplace.id, marketplaceId))
.limit(1)
.then((rows) => rows[0])
} else {
// Query without state
marketplaceEntry = await db
.select({
id: schema.marketplace.id,
workflowId: schema.marketplace.workflowId,
name: schema.marketplace.name,
description: schema.marketplace.description,
authorId: schema.marketplace.authorId,
authorName: schema.marketplace.authorName,
views: schema.marketplace.views,
category: schema.marketplace.category,
createdAt: schema.marketplace.createdAt,
updatedAt: schema.marketplace.updatedAt,
id: marketplace.id,
workflowId: marketplace.workflowId,
name: marketplace.name,
description: marketplace.description,
authorId: marketplace.authorId,
authorName: marketplace.authorName,
views: marketplace.views,
category: marketplace.category,
createdAt: marketplace.createdAt,
updatedAt: marketplace.updatedAt,
})
.from(schema.marketplace)
.where(eq(schema.marketplace.id, marketplaceId))
.from(marketplace)
.where(eq(marketplace.id, marketplaceId))
.limit(1)
.then((rows) => rows[0])
}
@@ -183,21 +183,19 @@ export async function GET(request: NextRequest) {
// Define common fields to select
const baseFields = {
id: schema.marketplace.id,
workflowId: schema.marketplace.workflowId,
name: schema.marketplace.name,
description: schema.marketplace.description,
authorName: schema.marketplace.authorName,
views: schema.marketplace.views,
category: schema.marketplace.category,
createdAt: schema.marketplace.createdAt,
updatedAt: schema.marketplace.updatedAt,
id: marketplace.id,
workflowId: marketplace.workflowId,
name: marketplace.name,
description: marketplace.description,
authorName: marketplace.authorName,
views: marketplace.views,
category: marketplace.category,
createdAt: marketplace.createdAt,
updatedAt: marketplace.updatedAt,
}
// Add state if requested
const selectFields = includeState
? { ...baseFields, state: schema.marketplace.state }
: baseFields
const selectFields = includeState ? { ...baseFields, state: marketplace.state } : baseFields
// Determine which sections to fetch
const sections = sectionParam ? sectionParam.split(',') : ['popular', 'recent', 'byCategory']
@@ -206,8 +204,8 @@ export async function GET(request: NextRequest) {
if (sections.includes('popular')) {
result.popular = await db
.select(selectFields)
.from(schema.marketplace)
.orderBy(desc(schema.marketplace.views))
.from(marketplace)
.orderBy(desc(marketplace.views))
.limit(limit)
}
@@ -215,8 +213,8 @@ export async function GET(request: NextRequest) {
if (sections.includes('recent')) {
result.recent = await db
.select(selectFields)
.from(schema.marketplace)
.orderBy(desc(schema.marketplace.createdAt))
.from(marketplace)
.orderBy(desc(marketplace.createdAt))
.limit(limit)
}
@@ -255,9 +253,9 @@ export async function GET(request: NextRequest) {
categoriesToFetch.map(async (categoryValue) => {
const categoryItems = await db
.select(selectFields)
.from(schema.marketplace)
.where(eq(schema.marketplace.category, categoryValue))
.orderBy(desc(schema.marketplace.views))
.from(marketplace)
.where(eq(marketplace.category, categoryValue))
.orderBy(desc(marketplace.views))
.limit(limit)
// Always add the category to the result, even if empty
@@ -328,10 +326,10 @@ export async function POST(request: NextRequest) {
// Find the marketplace entry
const marketplaceEntry = await db
.select({
id: schema.marketplace.id,
id: marketplace.id,
})
.from(schema.marketplace)
.where(eq(schema.marketplace.id, id))
.from(marketplace)
.where(eq(marketplace.id, id))
.limit(1)
.then((rows) => rows[0])
@@ -342,11 +340,11 @@ export async function POST(request: NextRequest) {
// Increment the view count
await db
.update(schema.marketplace)
.update(marketplace)
.set({
views: sql`${schema.marketplace.views} + 1`,
views: sql`${marketplace.views} + 1`,
})
.where(eq(schema.marketplace.id, id))
.where(eq(marketplace.id, id))
logger.info(`[${requestId}] Incremented view count for marketplace entry: ${id}`)

View File

@@ -0,0 +1,506 @@
import { randomUUID } from 'crypto'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
getEmailSubject,
renderBatchInvitationEmail,
renderInvitationEmail,
} from '@/components/emails/render-email'
import { getSession } from '@/lib/auth'
import {
validateBulkInvitations,
validateSeatAvailability,
} from '@/lib/billing/validation/seat-management'
import { sendEmail } from '@/lib/email/mailer'
import { validateAndNormalizeEmail } from '@/lib/email/utils'
import { createLogger } from '@/lib/logs/console-logger'
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
import { db } from '@/db'
import { invitation, member, organization, user, workspace, workspaceInvitation } from '@/db/schema'
const logger = createLogger('OrganizationInvitationsAPI')
interface WorkspaceInvitation {
workspaceId: string
permission: 'admin' | 'write' | 'read'
}
/**
* GET /api/organizations/[id]/invitations
* Get all pending invitations for an organization
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
// Verify user has access to this organization
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
const userRole = memberEntry[0].role
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
if (!hasAdminAccess) {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
// Get all pending invitations for the organization
const invitations = await db
.select({
id: invitation.id,
email: invitation.email,
role: invitation.role,
status: invitation.status,
expiresAt: invitation.expiresAt,
createdAt: invitation.createdAt,
inviterName: user.name,
inviterEmail: user.email,
})
.from(invitation)
.leftJoin(user, eq(invitation.inviterId, user.id))
.where(eq(invitation.organizationId, organizationId))
.orderBy(invitation.createdAt)
return NextResponse.json({
success: true,
data: {
invitations,
userRole,
},
})
} catch (error) {
logger.error('Failed to get organization invitations', {
organizationId: (await params).id,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST /api/organizations/[id]/invitations
* Create organization invitations with optional validation and batch workspace invitations
* Query parameters:
* - ?validate=true - Only validate, don't send invitations
* - ?batch=true - Include workspace invitations
*/
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const url = new URL(request.url)
const validateOnly = url.searchParams.get('validate') === 'true'
const isBatch = url.searchParams.get('batch') === 'true'
const body = await request.json()
const { email, emails, role = 'member', workspaceInvitations } = body
// Handle single invitation vs batch
const invitationEmails = email ? [email] : emails
// Validate input
if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) {
return NextResponse.json({ error: 'Email or emails array is required' }, { status: 400 })
}
if (!['member', 'admin'].includes(role)) {
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
}
// Verify user has admin access
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
if (!['owner', 'admin'].includes(memberEntry[0].role)) {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
// Handle validation-only requests
if (validateOnly) {
const validationResult = await validateBulkInvitations(organizationId, invitationEmails)
logger.info('Invitation validation completed', {
organizationId,
userId: session.user.id,
emailCount: invitationEmails.length,
result: validationResult,
})
return NextResponse.json({
success: true,
data: validationResult,
validatedBy: session.user.id,
validatedAt: new Date().toISOString(),
})
}
// Validate seat availability
const seatValidation = await validateSeatAvailability(organizationId, invitationEmails.length)
if (!seatValidation.canInvite) {
return NextResponse.json(
{
error: seatValidation.reason,
seatInfo: {
currentSeats: seatValidation.currentSeats,
maxSeats: seatValidation.maxSeats,
availableSeats: seatValidation.availableSeats,
seatsRequested: invitationEmails.length,
},
},
{ status: 400 }
)
}
// Get organization details
const organizationEntry = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (organizationEntry.length === 0) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
// Validate and normalize emails
const processedEmails = invitationEmails
.map((email: string) => {
const result = validateAndNormalizeEmail(email)
return result.isValid ? result.normalized : null
})
.filter(Boolean) as string[]
if (processedEmails.length === 0) {
return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 })
}
// Handle batch workspace invitations if provided
const validWorkspaceInvitations: WorkspaceInvitation[] = []
if (isBatch && workspaceInvitations && workspaceInvitations.length > 0) {
for (const wsInvitation of workspaceInvitations) {
// Check if user has admin permission on this workspace
const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId)
if (!canInvite) {
return NextResponse.json(
{
error: `You don't have permission to invite users to workspace ${wsInvitation.workspaceId}`,
},
{ status: 403 }
)
}
validWorkspaceInvitations.push(wsInvitation)
}
}
// Check for existing members
const existingMembers = await db
.select({ userEmail: user.email })
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(eq(member.organizationId, organizationId))
const existingEmails = existingMembers.map((m) => m.userEmail)
const newEmails = processedEmails.filter((email: string) => !existingEmails.includes(email))
// Check for existing pending invitations
const existingInvitations = await db
.select({ email: invitation.email })
.from(invitation)
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
const pendingEmails = existingInvitations.map((i) => i.email)
const emailsToInvite = newEmails.filter((email: string) => !pendingEmails.includes(email))
if (emailsToInvite.length === 0) {
return NextResponse.json(
{
error: 'All emails are already members or have pending invitations',
details: {
existingMembers: processedEmails.filter((email: string) =>
existingEmails.includes(email)
),
pendingInvitations: processedEmails.filter((email: string) =>
pendingEmails.includes(email)
),
},
},
{ status: 400 }
)
}
// Create invitations
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
const invitationsToCreate = emailsToInvite.map((email: string) => ({
id: randomUUID(),
email,
inviterId: session.user.id,
organizationId,
role,
status: 'pending' as const,
expiresAt,
createdAt: new Date(),
}))
await db.insert(invitation).values(invitationsToCreate)
// Create workspace invitations if batch mode
const workspaceInvitationIds: string[] = []
if (isBatch && validWorkspaceInvitations.length > 0) {
for (const email of emailsToInvite) {
for (const wsInvitation of validWorkspaceInvitations) {
const wsInvitationId = randomUUID()
const token = randomUUID()
await db.insert(workspaceInvitation).values({
id: wsInvitationId,
workspaceId: wsInvitation.workspaceId,
email,
inviterId: session.user.id,
role: 'member',
status: 'pending',
token,
permissions: wsInvitation.permission,
expiresAt,
createdAt: new Date(),
updatedAt: new Date(),
})
workspaceInvitationIds.push(wsInvitationId)
}
}
}
// Send invitation emails
const inviter = await db
.select({ name: user.name })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
for (const email of emailsToInvite) {
const orgInvitation = invitationsToCreate.find((inv) => inv.email === email)
if (!orgInvitation) continue
let emailResult
if (isBatch && validWorkspaceInvitations.length > 0) {
// Get workspace details for batch email
const workspaceDetails = await db
.select({
id: workspace.id,
name: workspace.name,
})
.from(workspace)
.where(
inArray(
workspace.id,
validWorkspaceInvitations.map((w) => w.workspaceId)
)
)
const workspaceInvitationsWithNames = validWorkspaceInvitations.map((wsInv) => ({
workspaceId: wsInv.workspaceId,
workspaceName:
workspaceDetails.find((w) => w.id === wsInv.workspaceId)?.name || 'Unknown Workspace',
permission: wsInv.permission,
}))
const emailHtml = await renderBatchInvitationEmail(
inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization',
role,
workspaceInvitationsWithNames,
`${process.env.NEXT_PUBLIC_BASE_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`
)
emailResult = await sendEmail({
to: email,
subject: getEmailSubject('batch-invitation'),
html: emailHtml,
emailType: 'transactional',
})
} else {
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization',
`${process.env.NEXT_PUBLIC_BASE_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`,
email
)
emailResult = await sendEmail({
to: email,
subject: getEmailSubject('invitation'),
html: emailHtml,
emailType: 'transactional',
})
}
if (!emailResult.success) {
logger.error('Failed to send invitation email', {
email,
error: emailResult.message,
})
}
}
logger.info('Organization invitations created', {
organizationId,
invitedBy: session.user.id,
invitationCount: invitationsToCreate.length,
emails: emailsToInvite,
role,
isBatch,
workspaceInvitationCount: workspaceInvitationIds.length,
})
return NextResponse.json({
success: true,
message: `${invitationsToCreate.length} invitation(s) sent successfully`,
data: {
invitationsSent: invitationsToCreate.length,
invitedEmails: emailsToInvite,
existingMembers: processedEmails.filter((email: string) => existingEmails.includes(email)),
pendingInvitations: processedEmails.filter((email: string) =>
pendingEmails.includes(email)
),
invalidEmails: invitationEmails.filter(
(email: string) => !validateAndNormalizeEmail(email)
),
workspaceInvitations: isBatch ? validWorkspaceInvitations.length : 0,
seatInfo: {
seatsUsed: seatValidation.currentSeats + invitationsToCreate.length,
maxSeats: seatValidation.maxSeats,
availableSeats: seatValidation.availableSeats - invitationsToCreate.length,
},
},
})
} catch (error) {
logger.error('Failed to create organization invitations', {
organizationId: (await params).id,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/organizations/[id]/invitations?invitationId=...
* Cancel a pending invitation
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const url = new URL(request.url)
const invitationId = url.searchParams.get('invitationId')
if (!invitationId) {
return NextResponse.json(
{ error: 'Invitation ID is required as query parameter' },
{ status: 400 }
)
}
// Verify user has admin access
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
if (!['owner', 'admin'].includes(memberEntry[0].role)) {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
// Cancel the invitation
const result = await db
.update(invitation)
.set({
status: 'cancelled',
})
.where(
and(
eq(invitation.id, invitationId),
eq(invitation.organizationId, organizationId),
eq(invitation.status, 'pending')
)
)
.returning()
if (result.length === 0) {
return NextResponse.json(
{ error: 'Invitation not found or already processed' },
{ status: 404 }
)
}
logger.info('Organization invitation cancelled', {
organizationId,
invitationId,
cancelledBy: session.user.id,
email: result[0].email,
})
return NextResponse.json({
success: true,
message: 'Invitation cancelled successfully',
})
} catch (error) {
logger.error('Failed to cancel organization invitation', {
organizationId: (await params).id,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,314 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { member, user, userStats } from '@/db/schema'
const logger = createLogger('OrganizationMemberAPI')
/**
* GET /api/organizations/[id]/members/[memberId]
* Get individual organization member details
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string; memberId: string }> }
) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId, memberId } = await params
const url = new URL(request.url)
const includeUsage = url.searchParams.get('include') === 'usage'
// Verify user has access to this organization
const userMember = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (userMember.length === 0) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
const userRole = userMember[0].role
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
// Get target member details
const memberQuery = db
.select({
id: member.id,
userId: member.userId,
organizationId: member.organizationId,
role: member.role,
createdAt: member.createdAt,
userName: user.name,
userEmail: user.email,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
.limit(1)
const memberEntry = await memberQuery
if (memberEntry.length === 0) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Check if user can view this member's details
const canViewDetails = hasAdminAccess || session.user.id === memberId
if (!canViewDetails) {
return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 })
}
let memberData = memberEntry[0]
// Include usage data if requested and user has permission
if (includeUsage && hasAdminAccess) {
const usageData = await db
.select({
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
billingPeriodStart: userStats.billingPeriodStart,
billingPeriodEnd: userStats.billingPeriodEnd,
usageLimitSetBy: userStats.usageLimitSetBy,
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
lastPeriodCost: userStats.lastPeriodCost,
})
.from(userStats)
.where(eq(userStats.userId, memberId))
.limit(1)
if (usageData.length > 0) {
memberData = {
...memberData,
usage: usageData[0],
} as typeof memberData & { usage: (typeof usageData)[0] }
}
}
return NextResponse.json({
success: true,
data: memberData,
userRole,
hasAdminAccess,
})
} catch (error) {
logger.error('Failed to get organization member', {
organizationId: (await params).id,
memberId: (await params).memberId,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PUT /api/organizations/[id]/members/[memberId]
* Update organization member role
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string; memberId: string }> }
) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId, memberId } = await params
const { role } = await request.json()
// Validate input
if (!role || !['admin', 'member'].includes(role)) {
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
}
// Verify user has admin access
const userMember = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (userMember.length === 0) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
if (!['owner', 'admin'].includes(userMember[0].role)) {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
// Check if target member exists
const targetMember = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
.limit(1)
if (targetMember.length === 0) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Prevent changing owner role
if (targetMember[0].role === 'owner') {
return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 })
}
// Prevent non-owners from promoting to admin
if (role === 'admin' && userMember[0].role !== 'owner') {
return NextResponse.json(
{ error: 'Only owners can promote members to admin' },
{ status: 403 }
)
}
// Update member role
const updatedMember = await db
.update(member)
.set({ role })
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
.returning()
if (updatedMember.length === 0) {
return NextResponse.json({ error: 'Failed to update member role' }, { status: 500 })
}
logger.info('Organization member role updated', {
organizationId,
memberId,
newRole: role,
updatedBy: session.user.id,
})
return NextResponse.json({
success: true,
message: 'Member role updated successfully',
data: {
id: updatedMember[0].id,
userId: updatedMember[0].userId,
role: updatedMember[0].role,
updatedBy: session.user.id,
},
})
} catch (error) {
logger.error('Failed to update organization member role', {
organizationId: (await params).id,
memberId: (await params).memberId,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/organizations/[id]/members/[memberId]
* Remove member from organization
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string; memberId: string }> }
) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId, memberId } = await params
// Verify user has admin access
const userMember = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (userMember.length === 0) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
const canRemoveMembers =
['owner', 'admin'].includes(userMember[0].role) || session.user.id === memberId
if (!canRemoveMembers) {
return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 })
}
// Check if target member exists
const targetMember = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
.limit(1)
if (targetMember.length === 0) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
// Prevent removing the owner
if (targetMember[0].role === 'owner') {
return NextResponse.json({ error: 'Cannot remove organization owner' }, { status: 400 })
}
// Remove member
const removedMember = await db
.delete(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
.returning()
if (removedMember.length === 0) {
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
}
logger.info('Organization member removed', {
organizationId,
removedMemberId: memberId,
removedBy: session.user.id,
wasSelfRemoval: session.user.id === memberId,
})
return NextResponse.json({
success: true,
message:
session.user.id === memberId
? 'You have left the organization'
: 'Member removed successfully',
data: {
removedMemberId: memberId,
removedBy: session.user.id,
removedAt: new Date().toISOString(),
},
})
} catch (error) {
logger.error('Failed to remove organization member', {
organizationId: (await params).id,
memberId: (await params).memberId,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,293 @@
import { randomUUID } from 'crypto'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email'
import { getSession } from '@/lib/auth'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
import { sendEmail } from '@/lib/email/mailer'
import { validateAndNormalizeEmail } from '@/lib/email/utils'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { invitation, member, organization, user, userStats } from '@/db/schema'
const logger = createLogger('OrganizationMembersAPI')
/**
* GET /api/organizations/[id]/members
* Get organization members with optional usage data
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const url = new URL(request.url)
const includeUsage = url.searchParams.get('include') === 'usage'
// Verify user has access to this organization
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
const userRole = memberEntry[0].role
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
// Get organization members
const query = db
.select({
id: member.id,
userId: member.userId,
organizationId: member.organizationId,
role: member.role,
createdAt: member.createdAt,
userName: user.name,
userEmail: user.email,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(eq(member.organizationId, organizationId))
// Include usage data if requested and user has admin access
if (includeUsage && hasAdminAccess) {
const membersWithUsage = await db
.select({
id: member.id,
userId: member.userId,
organizationId: member.organizationId,
role: member.role,
createdAt: member.createdAt,
userName: user.name,
userEmail: user.email,
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
billingPeriodStart: userStats.billingPeriodStart,
billingPeriodEnd: userStats.billingPeriodEnd,
usageLimitSetBy: userStats.usageLimitSetBy,
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(userStats, eq(user.id, userStats.userId))
.where(eq(member.organizationId, organizationId))
return NextResponse.json({
success: true,
data: membersWithUsage,
total: membersWithUsage.length,
userRole,
hasAdminAccess,
})
}
const members = await query
return NextResponse.json({
success: true,
data: members,
total: members.length,
userRole,
hasAdminAccess,
})
} catch (error) {
logger.error('Failed to get organization members', {
organizationId: (await params).id,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST /api/organizations/[id]/members
* Invite new member to organization
*/
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const { email, role = 'member' } = await request.json()
// Validate input
if (!email) {
return NextResponse.json({ error: 'Email is required' }, { status: 400 })
}
if (!['admin', 'member'].includes(role)) {
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
}
// Validate and normalize email
const { isValid, normalized: normalizedEmail } = validateAndNormalizeEmail(email)
if (!isValid) {
return NextResponse.json({ error: 'Invalid email format' }, { status: 400 })
}
// Verify user has admin access
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
if (!['owner', 'admin'].includes(memberEntry[0].role)) {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
// Check seat availability
const seatValidation = await validateSeatAvailability(organizationId, 1)
if (!seatValidation.canInvite) {
return NextResponse.json(
{
error: `Cannot invite member. Using ${seatValidation.currentSeats} of ${seatValidation.maxSeats} seats.`,
details: seatValidation,
},
{ status: 400 }
)
}
// Check if user is already a member
const existingUser = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, normalizedEmail))
.limit(1)
if (existingUser.length > 0) {
const existingMember = await db
.select()
.from(member)
.where(
and(eq(member.organizationId, organizationId), eq(member.userId, existingUser[0].id))
)
.limit(1)
if (existingMember.length > 0) {
return NextResponse.json(
{ error: 'User is already a member of this organization' },
{ status: 400 }
)
}
}
// Check for existing pending invitation
const existingInvitation = await db
.select()
.from(invitation)
.where(
and(
eq(invitation.organizationId, organizationId),
eq(invitation.email, normalizedEmail),
eq(invitation.status, 'pending')
)
)
.limit(1)
if (existingInvitation.length > 0) {
return NextResponse.json(
{ error: 'Pending invitation already exists for this email' },
{ status: 400 }
)
}
// Create invitation
const invitationId = randomUUID()
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry
await db.insert(invitation).values({
id: invitationId,
email: normalizedEmail,
inviterId: session.user.id,
organizationId,
role,
status: 'pending',
expiresAt,
createdAt: new Date(),
})
const organizationEntry = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
const inviter = await db
.select({ name: user.name })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization',
`${process.env.NEXT_PUBLIC_BASE_URL}/api/organizations/invitations/accept?id=${invitationId}`,
normalizedEmail
)
const emailResult = await sendEmail({
to: normalizedEmail,
subject: getEmailSubject('invitation'),
html: emailHtml,
emailType: 'transactional',
})
if (emailResult.success) {
logger.info('Member invitation sent', {
email: normalizedEmail,
organizationId,
invitationId,
role,
})
} else {
logger.error('Failed to send invitation email', {
email: normalizedEmail,
error: emailResult.message,
})
// Don't fail the request if email fails
}
return NextResponse.json({
success: true,
message: `Invitation sent to ${normalizedEmail}`,
data: {
invitationId,
email: normalizedEmail,
role,
expiresAt,
},
})
} catch (error) {
logger.error('Failed to invite organization member', {
organizationId: (await params).id,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,248 @@
import { and, eq, ne } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import {
getOrganizationSeatAnalytics,
getOrganizationSeatInfo,
updateOrganizationSeats,
} from '@/lib/billing/validation/seat-management'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { member, organization } from '@/db/schema'
const logger = createLogger('OrganizationAPI')
/**
* GET /api/organizations/[id]
* Get organization details including settings and seat information
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const url = new URL(request.url)
const includeSeats = url.searchParams.get('include') === 'seats'
// Verify user has access to this organization
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
// Get organization data
const organizationEntry = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (organizationEntry.length === 0) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
const userRole = memberEntry[0].role
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
const response: any = {
success: true,
data: {
id: organizationEntry[0].id,
name: organizationEntry[0].name,
slug: organizationEntry[0].slug,
logo: organizationEntry[0].logo,
metadata: organizationEntry[0].metadata,
createdAt: organizationEntry[0].createdAt,
updatedAt: organizationEntry[0].updatedAt,
},
userRole,
hasAdminAccess,
}
// Include seat information if requested
if (includeSeats) {
const seatInfo = await getOrganizationSeatInfo(organizationId)
if (seatInfo) {
response.data.seats = seatInfo
}
// Include analytics for admins
if (hasAdminAccess) {
const analytics = await getOrganizationSeatAnalytics(organizationId)
if (analytics) {
response.data.seatAnalytics = analytics
}
}
}
return NextResponse.json(response)
} catch (error) {
logger.error('Failed to get organization', {
organizationId: (await params).id,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* PUT /api/organizations/[id]
* Update organization settings or seat count
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const body = await request.json()
const { name, slug, logo, seats } = body
// Verify user has admin access
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}
if (!['owner', 'admin'].includes(memberEntry[0].role)) {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
}
// Handle seat count update
if (seats !== undefined) {
if (typeof seats !== 'number' || seats < 1) {
return NextResponse.json({ error: 'Invalid seat count' }, { status: 400 })
}
const result = await updateOrganizationSeats(organizationId, seats, session.user.id)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
logger.info('Organization seat count updated', {
organizationId,
newSeatCount: seats,
updatedBy: session.user.id,
})
return NextResponse.json({
success: true,
message: 'Seat count updated successfully',
data: {
seats: seats,
updatedBy: session.user.id,
updatedAt: new Date().toISOString(),
},
})
}
// Handle settings update
if (name !== undefined || slug !== undefined || logo !== undefined) {
// Validate required fields
if (name !== undefined && (!name || typeof name !== 'string' || name.trim().length === 0)) {
return NextResponse.json({ error: 'Organization name is required' }, { status: 400 })
}
if (slug !== undefined && (!slug || typeof slug !== 'string' || slug.trim().length === 0)) {
return NextResponse.json({ error: 'Organization slug is required' }, { status: 400 })
}
// Validate slug format
if (slug !== undefined) {
const slugRegex = /^[a-z0-9-_]+$/
if (!slugRegex.test(slug)) {
return NextResponse.json(
{
error: 'Slug can only contain lowercase letters, numbers, hyphens, and underscores',
},
{ status: 400 }
)
}
// Check if slug is already taken by another organization
const existingSlug = await db
.select()
.from(organization)
.where(and(eq(organization.slug, slug), ne(organization.id, organizationId)))
.limit(1)
if (existingSlug.length > 0) {
return NextResponse.json({ error: 'This slug is already taken' }, { status: 400 })
}
}
// Build update object with only provided fields
const updateData: any = { updatedAt: new Date() }
if (name !== undefined) updateData.name = name.trim()
if (slug !== undefined) updateData.slug = slug.trim()
if (logo !== undefined) updateData.logo = logo || null
// Update organization
const updatedOrg = await db
.update(organization)
.set(updateData)
.where(eq(organization.id, organizationId))
.returning()
if (updatedOrg.length === 0) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}
logger.info('Organization settings updated', {
organizationId,
updatedBy: session.user.id,
changes: { name, slug, logo },
})
return NextResponse.json({
success: true,
message: 'Organization updated successfully',
data: {
id: updatedOrg[0].id,
name: updatedOrg[0].name,
slug: updatedOrg[0].slug,
logo: updatedOrg[0].logo,
updatedAt: updatedOrg[0].updatedAt,
},
})
}
return NextResponse.json({ error: 'No valid fields provided for update' }, { status: 400 })
} catch (error) {
logger.error('Failed to update organization', {
organizationId: (await params).id,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// DELETE method removed - organization deletion not implemented
// If deletion is needed in the future, it should be implemented with proper
// cleanup of subscriptions, members, workspaces, and billing data

View File

@@ -0,0 +1,209 @@
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { member, permissions, user, workspace, workspaceMember } from '@/db/schema'
const logger = createLogger('OrganizationWorkspacesAPI')
/**
* GET /api/organizations/[id]/workspaces
* Get workspaces related to the organization with optional filtering
* Query parameters:
* - ?available=true - Only workspaces where user can invite others (admin permissions)
* - ?member=userId - Workspaces where specific member has access
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: organizationId } = await params
const url = new URL(request.url)
const availableOnly = url.searchParams.get('available') === 'true'
const memberId = url.searchParams.get('member')
// Verify user is a member of this organization
const memberEntry = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)
if (memberEntry.length === 0) {
return NextResponse.json(
{
error: 'Forbidden - Not a member of this organization',
},
{ status: 403 }
)
}
const userRole = memberEntry[0].role
const hasAdminAccess = ['owner', 'admin'].includes(userRole)
if (availableOnly) {
// Get workspaces where user has admin permissions (can invite others)
const availableWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt,
isOwner: eq(workspace.ownerId, session.user.id),
permissionType: permissions.permissionType,
})
.from(workspace)
.leftJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(permissions.userId, session.user.id)
)
)
.where(
or(
// User owns the workspace
eq(workspace.ownerId, session.user.id),
// User has admin permission on the workspace
and(
eq(permissions.userId, session.user.id),
eq(permissions.entityType, 'workspace'),
eq(permissions.permissionType, 'admin')
)
)
)
// Filter and format the results
const workspacesWithInvitePermission = availableWorkspaces
.filter((workspace) => {
// Include if user owns the workspace OR has admin permission
return workspace.isOwner || workspace.permissionType === 'admin'
})
.map((workspace) => ({
id: workspace.id,
name: workspace.name,
isOwner: workspace.isOwner,
canInvite: true, // All returned workspaces have invite permission
createdAt: workspace.createdAt,
}))
logger.info('Retrieved available workspaces for organization member', {
organizationId,
userId: session.user.id,
workspaceCount: workspacesWithInvitePermission.length,
})
return NextResponse.json({
success: true,
data: {
workspaces: workspacesWithInvitePermission,
totalCount: workspacesWithInvitePermission.length,
filter: 'available',
},
})
}
if (memberId && hasAdminAccess) {
// Get workspaces where specific member has access (admin only)
const memberWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt,
isOwner: eq(workspace.ownerId, memberId),
permissionType: permissions.permissionType,
joinedAt: workspaceMember.joinedAt,
})
.from(workspace)
.leftJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(permissions.userId, memberId)
)
)
.leftJoin(
workspaceMember,
and(eq(workspaceMember.workspaceId, workspace.id), eq(workspaceMember.userId, memberId))
)
.where(
or(
// Member owns the workspace
eq(workspace.ownerId, memberId),
// Member has permissions on the workspace
and(eq(permissions.userId, memberId), eq(permissions.entityType, 'workspace'))
)
)
const formattedWorkspaces = memberWorkspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
isOwner: workspace.isOwner,
permission: workspace.permissionType,
joinedAt: workspace.joinedAt,
createdAt: workspace.createdAt,
}))
return NextResponse.json({
success: true,
data: {
workspaces: formattedWorkspaces,
totalCount: formattedWorkspaces.length,
filter: 'member',
memberId,
},
})
}
// Default: Get all workspaces (basic info only for regular members)
if (!hasAdminAccess) {
return NextResponse.json({
success: true,
data: {
workspaces: [],
totalCount: 0,
message: 'Workspace access information is only available to organization admins',
},
})
}
// For admins: Get summary of all workspaces
const allWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
createdAt: workspace.createdAt,
ownerName: user.name,
})
.from(workspace)
.leftJoin(user, eq(workspace.ownerId, user.id))
return NextResponse.json({
success: true,
data: {
workspaces: allWorkspaces,
totalCount: allWorkspaces.length,
filter: 'all',
},
userRole,
hasAdminAccess,
})
} catch (error) {
logger.error('Failed to get organization workspaces', { error })
return NextResponse.json(
{
error: 'Internal server error',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,378 @@
import { randomUUID } from 'crypto'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { invitation, member, permissions, workspaceInvitation, workspaceMember } from '@/db/schema'
const logger = createLogger('OrganizationInvitationAcceptance')
// Accept an organization invitation and any associated workspace invitations
export async function GET(req: NextRequest) {
const invitationId = req.nextUrl.searchParams.get('id')
if (!invitationId) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=missing-invitation-id',
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
)
)
}
const session = await getSession()
if (!session?.user?.id) {
// Redirect to login, user will be redirected back after login
return NextResponse.redirect(
new URL(
`/invite/organization?id=${invitationId}`,
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
)
)
}
try {
// Find the organization invitation
const invitationResult = await db
.select()
.from(invitation)
.where(eq(invitation.id, invitationId))
.limit(1)
if (invitationResult.length === 0) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=invalid-invitation',
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
)
)
}
const orgInvitation = invitationResult[0]
// Check if invitation has expired
if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=expired',
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
)
)
}
// Check if invitation is still pending
if (orgInvitation.status !== 'pending') {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=already-processed',
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
)
)
}
// Verify the email matches the current user
if (orgInvitation.email !== session.user.email) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=email-mismatch',
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
)
)
}
// Check if user is already a member of the organization
const existingMember = await db
.select()
.from(member)
.where(
and(
eq(member.organizationId, orgInvitation.organizationId),
eq(member.userId, session.user.id)
)
)
.limit(1)
if (existingMember.length > 0) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=already-member',
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
)
)
}
// Start transaction to accept both organization and workspace invitations
await db.transaction(async (tx) => {
// Accept organization invitation - add user as member
await tx.insert(member).values({
id: randomUUID(),
userId: session.user.id,
organizationId: orgInvitation.organizationId,
role: orgInvitation.role,
createdAt: new Date(),
})
// Mark organization invitation as accepted
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
// Find and accept any pending workspace invitations for the same email
const workspaceInvitations = await tx
.select()
.from(workspaceInvitation)
.where(
and(
eq(workspaceInvitation.email, orgInvitation.email),
eq(workspaceInvitation.status, 'pending')
)
)
for (const wsInvitation of workspaceInvitations) {
// Check if invitation hasn't expired
if (
wsInvitation.expiresAt &&
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
) {
// Check if user isn't already a member of the workspace
const existingWorkspaceMember = await tx
.select()
.from(workspaceMember)
.where(
and(
eq(workspaceMember.workspaceId, wsInvitation.workspaceId),
eq(workspaceMember.userId, session.user.id)
)
)
.limit(1)
// Check if user doesn't already have permissions on the workspace
const existingPermission = await tx
.select()
.from(permissions)
.where(
and(
eq(permissions.userId, session.user.id),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, wsInvitation.workspaceId)
)
)
.limit(1)
if (existingWorkspaceMember.length === 0 && existingPermission.length === 0) {
// Add user as workspace member
await tx.insert(workspaceMember).values({
id: randomUUID(),
workspaceId: wsInvitation.workspaceId,
userId: session.user.id,
role: wsInvitation.role,
joinedAt: new Date(),
updatedAt: new Date(),
})
// Add workspace permissions
await tx.insert(permissions).values({
id: randomUUID(),
userId: session.user.id,
entityType: 'workspace',
entityId: wsInvitation.workspaceId,
permissionType: wsInvitation.permissions,
createdAt: new Date(),
updatedAt: new Date(),
})
// Mark workspace invitation as accepted
await tx
.update(workspaceInvitation)
.set({ status: 'accepted' })
.where(eq(workspaceInvitation.id, wsInvitation.id))
logger.info('Accepted workspace invitation', {
workspaceId: wsInvitation.workspaceId,
userId: session.user.id,
permission: wsInvitation.permissions,
})
}
}
}
})
logger.info('Successfully accepted batch invitation', {
organizationId: orgInvitation.organizationId,
userId: session.user.id,
role: orgInvitation.role,
})
// Redirect to success page or main app
return NextResponse.redirect(
new URL('/workspaces?invite=accepted', env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai')
)
} catch (error) {
logger.error('Failed to accept organization invitation', {
invitationId,
userId: session.user.id,
error,
})
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=server-error',
env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
)
)
}
}
// POST endpoint for programmatic acceptance (for API use)
export async function POST(req: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { invitationId } = await req.json()
if (!invitationId) {
return NextResponse.json({ error: 'Missing invitationId' }, { status: 400 })
}
// Similar logic to GET but return JSON response
const invitationResult = await db
.select()
.from(invitation)
.where(eq(invitation.id, invitationId))
.limit(1)
if (invitationResult.length === 0) {
return NextResponse.json({ error: 'Invalid invitation' }, { status: 404 })
}
const orgInvitation = invitationResult[0]
if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) {
return NextResponse.json({ error: 'Invitation expired' }, { status: 400 })
}
if (orgInvitation.status !== 'pending') {
return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 })
}
if (orgInvitation.email !== session.user.email) {
return NextResponse.json({ error: 'Email mismatch' }, { status: 403 })
}
// Check if user is already a member
const existingMember = await db
.select()
.from(member)
.where(
and(
eq(member.organizationId, orgInvitation.organizationId),
eq(member.userId, session.user.id)
)
)
.limit(1)
if (existingMember.length > 0) {
return NextResponse.json({ error: 'Already a member' }, { status: 400 })
}
let acceptedWorkspaces = 0
// Accept invitations in transaction
await db.transaction(async (tx) => {
// Accept organization invitation
await tx.insert(member).values({
id: randomUUID(),
userId: session.user.id,
organizationId: orgInvitation.organizationId,
role: orgInvitation.role,
createdAt: new Date(),
})
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
// Accept workspace invitations
const workspaceInvitations = await tx
.select()
.from(workspaceInvitation)
.where(
and(
eq(workspaceInvitation.email, orgInvitation.email),
eq(workspaceInvitation.status, 'pending')
)
)
for (const wsInvitation of workspaceInvitations) {
if (
wsInvitation.expiresAt &&
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
) {
const existingWorkspaceMember = await tx
.select()
.from(workspaceMember)
.where(
and(
eq(workspaceMember.workspaceId, wsInvitation.workspaceId),
eq(workspaceMember.userId, session.user.id)
)
)
.limit(1)
const existingPermission = await tx
.select()
.from(permissions)
.where(
and(
eq(permissions.userId, session.user.id),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, wsInvitation.workspaceId)
)
)
.limit(1)
if (existingWorkspaceMember.length === 0 && existingPermission.length === 0) {
await tx.insert(workspaceMember).values({
id: randomUUID(),
workspaceId: wsInvitation.workspaceId,
userId: session.user.id,
role: wsInvitation.role,
joinedAt: new Date(),
updatedAt: new Date(),
})
await tx.insert(permissions).values({
id: randomUUID(),
userId: session.user.id,
entityType: 'workspace',
entityId: wsInvitation.workspaceId,
permissionType: wsInvitation.permissions,
createdAt: new Date(),
updatedAt: new Date(),
})
await tx
.update(workspaceInvitation)
.set({ status: 'accepted' })
.where(eq(workspaceInvitation.id, wsInvitation.id))
acceptedWorkspaces++
}
}
}
})
return NextResponse.json({
success: true,
message: `Successfully joined organization and ${acceptedWorkspaces} workspace(s)`,
organizationId: orgInvitation.organizationId,
workspacesJoined: acceptedWorkspaces,
})
} catch (error) {
logger.error('Failed to accept organization invitation via API', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -3,6 +3,7 @@ import { and, eq, lte, not, sql } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { createLogger } from '@/lib/logs/console-logger'
import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
import { buildTraceSpans } from '@/lib/logs/trace-spans'
@@ -12,7 +13,6 @@ import {
getScheduleTimeValues,
getSubBlockValue,
} from '@/lib/schedules/utils'
import { checkServerSideUsageLimits } from '@/lib/usage-monitor'
import { decryptSecret } from '@/lib/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'

View File

@@ -0,0 +1,179 @@
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 { createLogger } from '@/lib/logs/console-logger'
import { isOrganizationOwnerOrAdmin } from '@/lib/permissions/utils'
const logger = createLogger('UnifiedUsageLimitsAPI')
/**
* Unified Usage Limits Endpoint
* GET/PUT /api/usage-limits?context=user|member&userId=<id>&organizationId=<id>
*
*/
export async function GET(request: NextRequest) {
const session = await getSession()
try {
if (!session?.user?.id) {
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')
// Validate context
if (!['user', 'member'].includes(context)) {
return NextResponse.json(
{ error: 'Invalid context. Must be "user" or "member"' },
{ 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(
{ error: "Cannot view other users' usage information" },
{ status: 403 }
)
}
// Get usage limit info
const usageLimitInfo = await getUserUsageLimitInfo(userId)
return NextResponse.json({
success: true,
context,
userId,
organizationId,
data: usageLimitInfo,
})
} catch (error) {
logger.error('Failed to get usage limit info', {
userId: session?.user?.id,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PUT(request: NextRequest) {
const session = await getSession()
try {
if (!session?.user?.id) {
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()
if (typeof limit !== 'number' || limit < 0) {
return NextResponse.json(
{ error: 'Invalid limit. Must be a positive number' },
{ 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
if (!organizationId) {
return NextResponse.json(
{ error: 'Organization ID is required when context=member' },
{ 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 }
)
}
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 }
)
}
// Return updated limit info
const updatedInfo = await getUserUsageLimitInfo(userId)
return NextResponse.json({
success: true,
context,
userId,
organizationId,
data: updatedInfo,
})
} catch (error) {
logger.error('Failed to update usage limit', {
userId: session?.user?.id,
error,
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,69 +0,0 @@
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { userStats, workflow } from '@/db/schema'
const logger = createLogger('UserStatsAPI')
/**
* GET endpoint to retrieve user statistics including the count of workflows
*/
export async function GET(request: NextRequest) {
try {
// Get the user session
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized user stats access attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// Get workflow count for user
const [workflowCountResult] = await db
.select({ count: sql`count(*)::int` })
.from(workflow)
.where(eq(workflow.userId, userId))
const workflowCount = workflowCountResult?.count || 0
// Get user stats record
const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
// If no stats record exists, create one
if (userStatsRecords.length === 0) {
const newStats = {
id: crypto.randomUUID(),
userId,
totalManualExecutions: 0,
totalApiCalls: 0,
totalWebhookTriggers: 0,
totalScheduledExecutions: 0,
totalChatExecutions: 0,
totalTokensUsed: 0,
totalCost: '0.00',
lastActive: new Date(),
}
await db.insert(userStats).values(newStats)
// Return the newly created stats with workflow count
return NextResponse.json({
...newStats,
workflowCount,
})
}
// Return stats with workflow count
const stats = userStatsRecords[0]
return NextResponse.json({
...stats,
workflowCount,
})
} catch (error) {
logger.error('Error fetching user stats:', error)
return NextResponse.json({ error: 'Failed to fetch user statistics' }, { status: 500 })
}
}

View File

@@ -1,251 +0,0 @@
/**
* Tests for Subscription Seats Update API
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
mockDb,
mockLogger,
mockPersonalSubscription,
mockRegularMember,
mockSubscription,
mockTeamSubscription,
mockUser,
} from '@/app/api/__test-utils__/utils'
describe('Subscription Seats Update API Routes', () => {
beforeEach(() => {
vi.resetModules()
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: mockUser,
}),
}))
vi.doMock('@/lib/subscription/utils', () => ({
checkEnterprisePlan: vi.fn().mockReturnValue(true),
}))
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@/db', () => ({
db: mockDb,
}))
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([mockSubscription]),
})
const mockSetFn = vi.fn().mockReturnThis()
const mockWhereFn = vi.fn().mockResolvedValue([{ affected: 1 }])
mockDb.update.mockReturnValue({
set: mockSetFn,
where: mockWhereFn,
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('POST handler', () => {
it('should encounter a permission error when trying to update subscription seats', async () => {
vi.doMock('@/lib/subscription/utils', () => ({
checkEnterprisePlan: vi.fn().mockReturnValue(true),
}))
mockDb.select.mockImplementationOnce(() => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([mockSubscription]),
}))
mockDb.select.mockImplementationOnce(() => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([]),
}))
const req = createMockRequest('POST', {
seats: 10,
})
const { POST } = await import('./route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty(
'error',
'Unauthorized - you do not have permission to modify this subscription'
)
expect(mockDb.update).not.toHaveBeenCalled()
})
it('should reject team plan subscription updates', async () => {
vi.doMock('@/lib/subscription/utils', () => ({
checkEnterprisePlan: vi.fn().mockReturnValue(false),
}))
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([mockTeamSubscription]),
})
const req = createMockRequest('POST', {
seats: 10,
})
const { POST } = await import('./route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty(
'error',
'Only enterprise subscriptions can be updated through this endpoint'
)
expect(mockDb.update).not.toHaveBeenCalled()
})
it('should encounter permission issues with personal subscription updates', async () => {
vi.doMock('@/lib/subscription/utils', () => ({
checkEnterprisePlan: vi.fn().mockReturnValue(true),
}))
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([mockPersonalSubscription]),
})
const req = createMockRequest('POST', {
seats: 10,
})
const { POST } = await import('./route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error')
})
it('should reject updates from non-admin members', async () => {
vi.doMock('@/lib/subscription/utils', () => ({
checkEnterprisePlan: vi.fn().mockReturnValue(true),
}))
const mockSelectImpl = vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([mockSubscription]),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([mockRegularMember]),
})
mockDb.select.mockImplementation(mockSelectImpl)
const req = createMockRequest('POST', {
seats: 10,
})
const { POST } = await import('./route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error')
})
it('should reject invalid request parameters', async () => {
const req = createMockRequest('POST', {
seats: -5,
})
const { POST } = await import('./route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'Invalid request parameters')
expect(mockDb.update).not.toHaveBeenCalled()
})
it('should handle subscription not found with permission error', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([]),
})
const req = createMockRequest('POST', {
seats: 10,
})
const { POST } = await import('./route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error')
})
it('should handle authentication error', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
}))
const req = createMockRequest('POST', {
seats: 10,
})
const { POST } = await import('./route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Unauthorized')
expect(mockDb.update).not.toHaveBeenCalled()
})
it('should handle internal server error', async () => {
mockDb.select.mockImplementation(() => {
throw new Error('Database error')
})
const req = createMockRequest('POST', {
seats: 10,
})
const { POST } = await import('./route')
const response = await POST(req, { params: Promise.resolve({ id: 'sub-123' }) })
const data = await response.json()
expect(response.status).toBe(500)
expect(data).toHaveProperty('error', 'Failed to update subscription seats')
expect(mockLogger.error).toHaveBeenCalled()
})
})
})

View File

@@ -1,151 +0,0 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { checkEnterprisePlan } from '@/lib/subscription/utils'
import { db } from '@/db'
import { member, subscription } from '@/db/schema'
const logger = createLogger('SubscriptionSeatsUpdateAPI')
const updateSeatsSchema = z.object({
seats: z.number().int().min(1),
})
const subscriptionMetadataSchema = z
.object({
perSeatAllowance: z.number().positive().optional(),
totalAllowance: z.number().positive().optional(),
updatedAt: z.string().optional(),
})
.catchall(z.any())
interface SubscriptionMetadata {
perSeatAllowance?: number
totalAllowance?: number
updatedAt?: string
[key: string]: any
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const subscriptionId = (await params).id
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized seats update attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let body
try {
body = await request.json()
} catch (_parseError) {
return NextResponse.json(
{
error: 'Invalid JSON in request body',
},
{ status: 400 }
)
}
const validationResult = updateSeatsSchema.safeParse(body)
if (!validationResult.success) {
return NextResponse.json(
{
error: 'Invalid request parameters',
details: validationResult.error.format(),
},
{ status: 400 }
)
}
const { seats } = validationResult.data
const sub = await db
.select()
.from(subscription)
.where(eq(subscription.id, subscriptionId))
.then((rows) => rows[0])
if (!sub) {
return NextResponse.json({ error: 'Subscription not found' }, { status: 404 })
}
if (!checkEnterprisePlan(sub)) {
return NextResponse.json(
{ error: 'Only enterprise subscriptions can be updated through this endpoint' },
{ status: 400 }
)
}
const isPersonalSubscription = sub.referenceId === session.user.id
let hasAccess = isPersonalSubscription
if (!isPersonalSubscription) {
const mem = await db
.select()
.from(member)
.where(and(eq(member.userId, session.user.id), eq(member.organizationId, sub.referenceId)))
.then((rows) => rows[0])
hasAccess = mem && (mem.role === 'owner' || mem.role === 'admin')
}
if (!hasAccess) {
return NextResponse.json(
{ error: 'Unauthorized - you do not have permission to modify this subscription' },
{ status: 403 }
)
}
let validatedMetadata: SubscriptionMetadata
try {
validatedMetadata = subscriptionMetadataSchema.parse(sub.metadata || {})
} catch (error) {
logger.error('Invalid subscription metadata format', {
error,
subscriptionId,
metadata: sub.metadata,
})
return NextResponse.json(
{ error: 'Subscription metadata has invalid format' },
{ status: 400 }
)
}
if (validatedMetadata.perSeatAllowance && validatedMetadata.perSeatAllowance > 0) {
validatedMetadata.totalAllowance = seats * validatedMetadata.perSeatAllowance
validatedMetadata.updatedAt = new Date().toISOString()
}
await db
.update(subscription)
.set({
seats,
metadata: validatedMetadata,
})
.where(eq(subscription.id, subscriptionId))
logger.info('Subscription seats updated', {
subscriptionId,
oldSeats: sub.seats,
newSeats: seats,
userId: session.user.id,
})
return NextResponse.json({
success: true,
message: 'Subscription seats updated successfully',
seats,
metadata: validatedMetadata,
})
} catch (error) {
logger.error('Error updating subscription seats', {
error: error instanceof Error ? error.message : String(error),
})
return NextResponse.json({ error: 'Failed to update subscription seats' }, { status: 500 })
}
}

View File

@@ -1,78 +0,0 @@
import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { checkEnterprisePlan } from '@/lib/subscription/utils'
import { db } from '@/db'
import { member, subscription } from '@/db/schema'
const logger = createLogger('EnterpriseSubscriptionAPI')
export async function GET() {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const userId = session.user.id
const userSubscriptions = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
.limit(1)
if (userSubscriptions.length > 0 && checkEnterprisePlan(userSubscriptions[0])) {
const enterpriseSub = userSubscriptions[0]
logger.info('Found direct enterprise subscription', { userId, subId: enterpriseSub.id })
return NextResponse.json({
success: true,
subscription: enterpriseSub,
})
}
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
for (const { organizationId } of memberships) {
const orgSubscriptions = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.limit(1)
if (orgSubscriptions.length > 0 && checkEnterprisePlan(orgSubscriptions[0])) {
const enterpriseSub = orgSubscriptions[0]
logger.info('Found organization enterprise subscription', {
userId,
orgId: organizationId,
subId: enterpriseSub.id,
})
return NextResponse.json({
success: true,
subscription: enterpriseSub,
})
}
}
return NextResponse.json({
success: false,
subscription: null,
})
} catch (error) {
logger.error('Error fetching enterprise subscription:', error)
return NextResponse.json(
{
success: false,
error: 'Failed to fetch enterprise subscription data',
},
{ status: 500 }
)
}
}

View File

@@ -1,43 +0,0 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { getHighestPrioritySubscription } from '@/lib/subscription/subscription'
import { checkEnterprisePlan, checkTeamPlan } from '@/lib/subscription/utils'
const logger = createLogger('UserSubscriptionAPI')
export async function GET() {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const activeSub = await getHighestPrioritySubscription(session.user.id)
const isPaid =
activeSub?.status === 'active' &&
['pro', 'team', 'enterprise'].includes(activeSub?.plan ?? '')
const isPro = isPaid
const isTeam = checkTeamPlan(activeSub)
const isEnterprise = checkEnterprisePlan(activeSub)
return NextResponse.json({
isPaid,
isPro,
isTeam,
isEnterprise,
plan: activeSub?.plan || 'free',
status: activeSub?.status || null,
seats: activeSub?.seats || null,
metadata: activeSub?.metadata || null,
})
} catch (error) {
logger.error('Error fetching subscription:', error)
return NextResponse.json({ error: 'Failed to fetch subscription data' }, { status: 500 })
}
}

View File

@@ -1,34 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { checkUsageStatus } from '@/lib/usage-monitor'
const logger = createLogger('UserUsageAPI')
export async function GET(request: NextRequest) {
try {
// Get the authenticated user
const session = await getSession()
if (!session?.user?.id) {
logger.warn('Unauthorized usage data access attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get usage data using our monitor utility
const usageData = await checkUsageStatus(session.user.id)
// Set appropriate caching headers
const response = NextResponse.json(usageData)
// Cache for 5 minutes, private (user-specific data), must revalidate
response.headers.set('Cache-Control', 'private, max-age=300, must-revalidate')
// Add date header for age calculation
response.headers.set('Date', new Date().toUTCString())
return response
} catch (error) {
logger.error('Error checking usage data:', error)
return NextResponse.json({ error: 'Failed to check usage data' }, { status: 500 })
}
}

View File

@@ -7,7 +7,7 @@ import { apiKey } from '@/db/schema'
const logger = createLogger('ApiKeyAPI')
// DELETE /api/user/api-keys/[id] - Delete an API key
// DELETE /api/users/me/api-keys/[id] - Delete an API key
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }

View File

@@ -9,7 +9,7 @@ import { apiKey } from '@/db/schema'
const logger = createLogger('ApiKeysAPI')
// GET /api/user/api-keys - Get all API keys for the current user
// GET /api/users/me/api-keys - Get all API keys for the current user
export async function GET(request: NextRequest) {
try {
const session = await getSession()
@@ -45,7 +45,7 @@ export async function GET(request: NextRequest) {
}
}
// POST /api/user/api-keys - Create a new API key
// POST /api/users/me/api-keys - Create a new API key
export async function POST(request: NextRequest) {
try {
const session = await getSession()

View File

@@ -1,76 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { isRateLimited } from '@/lib/waitlist/rate-limiter'
import { addToWaitlist } from '@/lib/waitlist/service'
const waitlistSchema = z.object({
email: z.string().email('Please enter a valid email'),
})
export async function POST(request: NextRequest) {
const rateLimitCheck = await isRateLimited(request, 'waitlist')
if (rateLimitCheck.limited) {
return NextResponse.json(
{
success: false,
message: rateLimitCheck.message || 'Too many requests. Please try again later.',
retryAfter: rateLimitCheck.remainingTime,
},
{
status: 429,
headers: {
'Retry-After': String(rateLimitCheck.remainingTime || 60),
},
}
)
}
try {
// Parse the request body
const body = await request.json()
// Validate the request
const validatedData = waitlistSchema.safeParse(body)
if (!validatedData.success) {
return NextResponse.json(
{
success: false,
message: 'Invalid email address',
errors: validatedData.error.format(),
},
{ status: 400 }
)
}
const { email } = validatedData.data
// Add the email to the waitlist and send confirmation email
const result = await addToWaitlist(email)
if (!result.success) {
return NextResponse.json(
{
success: false,
message: result.message,
},
{ status: 400 }
)
}
return NextResponse.json({
success: true,
message: 'Successfully added to waitlist',
})
} catch (error) {
console.error('Waitlist API error:', error)
return NextResponse.json(
{
success: false,
message: 'An error occurred while processing your request',
},
{ status: 500 }
)
}
}

View File

@@ -1,6 +1,6 @@
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/env'
import { verifyCronAuth } from '@/lib/auth/internal'
import { Logger } from '@/lib/logs/console-logger'
import { acquireLock, releaseLock } from '@/lib/redis'
import { pollGmailWebhooks } from '@/lib/webhooks/gmail-polling-service'
@@ -20,16 +20,9 @@ export async function GET(request: NextRequest) {
let lockValue: string | undefined
try {
const authHeader = request.headers.get('authorization')
const webhookSecret = env.CRON_SECRET
if (!webhookSecret) {
return new NextResponse('Configuration error: Webhook secret is not set', { status: 500 })
}
if (!authHeader || authHeader !== `Bearer ${webhookSecret}`) {
logger.warn(`Unauthorized access attempt to Gmail polling endpoint (${requestId})`)
return new NextResponse('Unauthorized', { status: 401 })
const authError = verifyCronAuth(request, 'Gmail webhook polling')
if (authError) {
return authError
}
lockValue = requestId // unique value to identify the holder

View File

@@ -1,9 +1,9 @@
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { createLogger } from '@/lib/logs/console-logger'
import { acquireLock, hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis'
import { checkServerSideUsageLimits } from '@/lib/usage-monitor'
import {
fetchAndProcessAirtablePayloads,
handleSlackChallenge,

View File

@@ -2,10 +2,10 @@ import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { createLogger } from '@/lib/logs/console-logger'
import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { checkServerSideUsageLimits } from '@/lib/usage-monitor'
import { decryptSecret } from '@/lib/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import {

View File

@@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { marketplace, workflow } from '@/db/schema'
const logger = createLogger('PublicWorkflowAPI')
@@ -19,25 +19,25 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// First, check if the workflow exists and is published to the marketplace
const marketplaceEntry = await db
.select({
id: schema.marketplace.id,
workflowId: schema.marketplace.workflowId,
state: schema.marketplace.state,
name: schema.marketplace.name,
description: schema.marketplace.description,
authorId: schema.marketplace.authorId,
authorName: schema.marketplace.authorName,
id: marketplace.id,
workflowId: marketplace.workflowId,
state: marketplace.state,
name: marketplace.name,
description: marketplace.description,
authorId: marketplace.authorId,
authorName: marketplace.authorName,
})
.from(schema.marketplace)
.where(eq(schema.marketplace.workflowId, id))
.from(marketplace)
.where(eq(marketplace.workflowId, id))
.limit(1)
.then((rows) => rows[0])
if (!marketplaceEntry) {
// Check if workflow exists but is not in marketplace
const workflowExists = await db
.select({ id: schema.workflow.id })
.from(schema.workflow)
.where(eq(schema.workflow.id, id))
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)
.then((rows) => rows.length > 0)

View File

@@ -1,7 +1,7 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getUsersWithPermissions } from '@/lib/permissions/utils'
import { getUsersWithPermissions, hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
import { db } from '@/db'
import { permissions, type permissionTypeEnum, workspaceMember } from '@/db/schema'
@@ -79,21 +79,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
// Verify the current user has admin access to this workspace
const userPermissions = await db
.select()
.from(permissions)
.where(
and(
eq(permissions.userId, session.user.id),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId),
eq(permissions.permissionType, 'admin')
)
)
.limit(1)
// Verify the current user has admin access to this workspace (either direct or through organization)
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
if (userPermissions.length === 0) {
if (!hasAdminAccess) {
return NextResponse.json(
{ error: 'Admin access required to update permissions' },
{ status: 403 }

View File

@@ -40,7 +40,7 @@ export default function UnsubscribePage() {
// Validate the unsubscribe link
fetch(
`/api/user/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
)
.then((res) => res.json())
.then((data) => {
@@ -64,7 +64,7 @@ export default function UnsubscribePage() {
setProcessing(true)
try {
const response = await fetch('/api/user/settings/unsubscribe', {
const response = await fetch('/api/users/me/settings/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -3,7 +3,7 @@
import { TimerOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { isProd } from '@/lib/environment'
import { useUserSubscription } from '@/hooks/use-user-subscription'
import { useSubscriptionStore } from '@/stores/subscription/store'
import FilterSection from './components/filter-section'
import FolderFilter from './components/folder'
import Level from './components/level'
@@ -15,7 +15,9 @@ import Workflow from './components/workflow'
* Filters component for logs page - includes timeline and other filter options
*/
export function Filters() {
const { isPaid, isLoading } = useUserSubscription()
const { getSubscriptionStatus, isLoading } = useSubscriptionStore()
const subscription = getSubscriptionStatus()
const isPaid = subscription.isPaid
const handleUpgradeClick = (e: React.MouseEvent) => {
e.preventDefault()

View File

@@ -101,7 +101,7 @@ export function DeployForm({
setIsCreating(true)
try {
const response = await fetch('/api/user/api-keys', {
const response = await fetch('/api/users/me/api-keys', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -152,7 +152,7 @@ export function DeployModal({
try {
setKeysLoaded(false)
const response = await fetch('/api/user/api-keys')
const response = await fetch('/api/users/me/api-keys')
if (response.ok) {
const data = await response.json()

View File

@@ -46,6 +46,7 @@ import { useFolderStore } from '@/stores/folders/store'
import { useNotificationStore } from '@/stores/notifications/store'
import { usePanelStore } from '@/stores/panel/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { useSubscriptionStore } from '@/stores/subscription/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -65,7 +66,11 @@ import { UserAvatarStack } from './components/user-avatar-stack/user-avatar-stac
const logger = createLogger('ControlBar')
// Cache for usage data to prevent excessive API calls
let usageDataCache = {
let usageDataCache: {
data: any | null
timestamp: number
expirationMs: number
} = {
data: null,
timestamp: 0,
// Cache expires after 1 minute
@@ -338,16 +343,13 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
}, [session?.user?.id, completedRuns, isRegistryLoading])
/**
* Check user usage data with caching to prevent excessive API calls
* @param userId User ID to check usage for
* @param forceRefresh Whether to force a fresh API call ignoring cache
* @returns Usage data or null if error
* Check user usage limits and cache results
*/
async function checkUserUsage(userId: string, forceRefresh = false): Promise<any | null> {
const now = Date.now()
const cacheAge = now - usageDataCache.timestamp
// Use cache if available and not expired
// Return cached data if still valid and not forcing refresh
if (!forceRefresh && usageDataCache.data && cacheAge < usageDataCache.expirationMs) {
logger.info('Using cached usage data', {
cacheAge: `${Math.round(cacheAge / 1000)}s`,
@@ -356,12 +358,15 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
}
try {
const response = await fetch('/api/user/usage')
if (!response.ok) {
throw new Error('Failed to fetch usage data')
// Use subscription store to get usage data
const { getUsage, refresh } = useSubscriptionStore.getState()
// Force refresh if requested
if (forceRefresh) {
await refresh()
}
const usage = await response.json()
const usage = getUsage()
// Update cache
usageDataCache = {

View File

@@ -63,7 +63,7 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
setIsLoading(true)
try {
const response = await fetch('/api/user/api-keys')
const response = await fetch('/api/users/me/api-keys')
if (response.ok) {
const data = await response.json()
setApiKeys(data.keys || [])
@@ -81,7 +81,7 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
setIsCreating(true)
try {
const response = await fetch('/api/user/api-keys', {
const response = await fetch('/api/users/me/api-keys', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -113,7 +113,7 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
if (!userId || !deleteKey) return
try {
const response = await fetch(`/api/user/api-keys/${deleteKey.id}`, {
const response = await fetch(`/api/users/me/api-keys/${deleteKey.id}`, {
method: 'DELETE',
})

View File

@@ -10,6 +10,7 @@ import {
} from 'lucide-react'
import { isDev } from '@/lib/environment'
import { cn } from '@/lib/utils'
import { useSubscriptionStore } from '@/stores/subscription/store'
interface SettingsNavigationProps {
activeSection: string
@@ -24,8 +25,7 @@ interface SettingsNavigationProps {
| 'team'
| 'privacy'
) => void
isTeam?: boolean
isEnterprise?: boolean
hasOrganization: boolean
}
type NavigationItem = {
@@ -93,16 +93,18 @@ const allNavigationItems: NavigationItem[] = [
export function SettingsNavigation({
activeSection,
onSectionChange,
isTeam = false,
isEnterprise = false,
hasOrganization,
}: SettingsNavigationProps) {
const { getSubscriptionStatus } = useSubscriptionStore()
const subscription = getSubscriptionStatus()
const navigationItems = allNavigationItems.filter((item) => {
if (item.hideInDev && isDev) {
return false
}
// Hide team tab if user doesn't have team or enterprisesubscription
if (item.requiresTeam && !isTeam && !isEnterprise) {
// Hide team tab if user doesn't have team or enterprise subscription
if (item.requiresTeam && !subscription.isTeam && !subscription.isEnterprise) {
return false
}

View File

@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { AlertCircle } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { useActiveOrganization, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('BillingSummary')
interface BillingSummaryData {
type: 'individual' | 'organization'
plan: string
currentUsage: number
planMinimum: number
projectedCharge: number
usageLimit: number
percentUsed: number
isWarning: boolean
isExceeded: boolean
daysRemaining: number
organizationData?: {
seatCount: number
averageUsagePerSeat: number
totalMinimum: number
}
}
interface BillingSummaryProps {
showDetails?: boolean
className?: string
onDataLoaded?: (data: BillingSummaryData) => void
onError?: (error: string) => void
}
export function BillingSummary({
showDetails = true,
className = '',
onDataLoaded,
onError,
}: BillingSummaryProps) {
const { data: session } = useSession()
const { data: activeOrg } = useActiveOrganization()
const [billingSummary, setBillingSummary] = useState<BillingSummaryData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function loadBillingSummary() {
if (!session?.user?.id) return
try {
setIsLoading(true)
const url = new URL('/api/billing', window.location.origin)
if (activeOrg?.id) {
url.searchParams.set('context', 'organization')
url.searchParams.set('id', activeOrg.id)
} else {
url.searchParams.set('context', 'user')
}
const response = await fetch(url.toString())
if (!response.ok) {
throw new Error(`Failed to fetch billing summary: ${response.statusText}`)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to load billing data')
}
setBillingSummary(result.data)
setError(null)
onDataLoaded?.(result.data)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load billing data'
setError(errorMessage)
onError?.(errorMessage)
logger.error('Failed to load billing summary', { error: err })
} finally {
setIsLoading(false)
}
}
loadBillingSummary()
}, [session?.user?.id, activeOrg?.id, onDataLoaded, onError])
const getStatusBadge = () => {
if (!billingSummary) return null
if (billingSummary.isExceeded) {
return (
<Badge variant='destructive' className='gap-1'>
<AlertCircle className='h-3 w-3' />
Limit Exceeded
</Badge>
)
}
if (billingSummary.isWarning) {
return (
<Badge variant='outline' className='gap-1 border-yellow-500 text-yellow-700'>
<AlertCircle className='h-3 w-3' />
Approaching Limit
</Badge>
)
}
return null
}
const formatCurrency = (amount: number) => `$${amount.toFixed(2)}`
if (isLoading || error || !billingSummary) {
return null
}
return (
<div className={className}>
{/* Status Badge */}
{getStatusBadge()}
{/* Billing Details */}
{showDetails && (
<div className='mt-3 space-y-1 text-muted-foreground text-xs'>
<div className='flex justify-between'>
<span>Plan minimum:</span>
<span>{formatCurrency(billingSummary.planMinimum)}</span>
</div>
<div className='flex justify-between'>
<span>Projected charge:</span>
<span className='font-medium'>{formatCurrency(billingSummary.projectedCharge)}</span>
</div>
{billingSummary.organizationData && (
<div className='flex justify-between'>
<span>Team seats:</span>
<span>{billingSummary.organizationData.seatCount}</span>
</div>
)}
</div>
)}
</div>
)
}
export type { BillingSummaryData }

View File

@@ -0,0 +1,156 @@
import { useState } from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useSubscription } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('CancelSubscription')
interface CancelSubscriptionProps {
subscription: {
plan: string
status: string | null
isPaid: boolean
}
subscriptionData?: {
periodEnd?: Date | null
}
}
export function CancelSubscription({ subscription, subscriptionData }: CancelSubscriptionProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const betterAuthSubscription = useSubscription()
// Don't show for free plans
if (!subscription.isPaid) {
return null
}
const handleCancel = async () => {
setIsLoading(true)
setError(null)
try {
// Use Better Auth client-side cancel method
// This redirects to Stripe Billing Portal where user can cancel
const result = await betterAuthSubscription.cancel?.({
returnUrl: window.location.href, // Return to current page after cancellation
})
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) {
const errorMessage = error instanceof Error ? error.message : 'Failed to cancel subscription'
setError(errorMessage)
logger.error('Failed to cancel subscription', { error })
} finally {
setIsLoading(false)
}
}
const getPeriodEndDate = () => {
return subscriptionData?.periodEnd || null
}
const formatDate = (date: Date | null) => {
if (!date) return 'end of current billing period'
try {
// Ensure we have a valid Date object
const dateObj = date instanceof Date ? date : new Date(date)
// Check if the date is valid
if (Number.isNaN(dateObj.getTime())) {
return 'end of current billing period'
}
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(dateObj)
} catch (error) {
console.warn('Invalid date in cancel subscription:', date, error)
return 'end of current billing period'
}
}
const periodEndDate = getPeriodEndDate()
return (
<>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
<span className='font-medium text-sm'>Cancel Subscription</span>
<p className='mt-1 text-muted-foreground text-xs'>
You'll keep access until {formatDate(periodEndDate)}
</p>
</div>
<Button
variant='destructive'
size='sm'
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
>
Cancel
</Button>
</div>
{error && (
<Alert variant='destructive'>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel {subscription.plan} subscription?</DialogTitle>
<DialogDescription>
You'll be redirected to Stripe to manage your subscription. You'll keep access until{' '}
{formatDate(periodEndDate)}, then downgrade to free plan.
</DialogDescription>
</DialogHeader>
<div className='space-y-3'>
<div className='rounded-lg bg-muted p-3 text-sm'>
<ul className='space-y-1 text-muted-foreground'>
<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>
<DialogFooter>
<Button variant='outline' onClick={() => setIsDialogOpen(false)} disabled={isLoading}>
Keep Subscription
</Button>
<Button variant='destructive' onClick={handleCancel} disabled={isLoading}>
{isLoading ? 'Redirecting...' : 'Continue'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,241 @@
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 EditMemberLimitDialogProps {
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 EditMemberLimitDialog({
open,
onOpenChange,
member,
onSave,
isLoading,
planType = 'team',
}: EditMemberLimitDialogProps) {
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)})`
)
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()}
/>
</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

@@ -0,0 +1,348 @@
import { useEffect, useState } from 'react'
import { AlertCircle, Settings2 } 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 { useOrganizationStore } from '@/stores/organization'
import type { MemberUsageData } from '@/stores/organization/types'
import { EditMemberLimitDialog } from './edit-member-limit-dialog'
const logger = createLogger('TeamUsageOverview')
interface TeamUsageOverviewProps {
hasAdminAccess: boolean
}
export function TeamUsageOverview({ hasAdminAccess }: TeamUsageOverviewProps) {
const { data: activeOrg } = useActiveOrganization()
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [selectedMember, setSelectedMember] = useState<MemberUsageData | null>(null)
const [isUpdating, setIsUpdating] = useState(false)
const {
organizationBillingData: billingData,
loadOrganizationBillingData,
updateMemberUsageLimit,
isLoadingOrgBilling,
error,
} = useOrganizationStore()
useEffect(() => {
if (activeOrg?.id) {
loadOrganizationBillingData(activeOrg.id)
}
}, [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')
}
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()
}
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>
</CardContent>
</Card>
</div>
)
}
if (error) {
return (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)
}
if (!billingData) {
return (
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertTitle>No Data</AlertTitle>
<AlertDescription>No billing data available for this organization.</AlertDescription>
</Alert>
)
}
const membersOverLimit = billingData.members?.filter((m) => m.isOverLimit).length || 0
const membersNearLimit =
billingData.members?.filter((m) => !m.isOverLimit && m.percentUsed >= 80).length || 0
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 */}
<EditMemberLimitDialog
open={editDialogOpen}
onOpenChange={handleCloseEditDialog}
member={selectedMember}
onSave={handleSaveLimit}
isLoading={isUpdating}
planType='team'
/>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console-logger'
import { useSubscriptionStore } from '@/stores/subscription/store'
const logger = createLogger('UsageLimitEditor')
interface UsageLimitEditorProps {
currentLimit: number
canEdit: boolean
minimumLimit: number
onLimitUpdated?: (newLimit: number) => void
}
export function UsageLimitEditor({
currentLimit,
canEdit,
minimumLimit,
onLimitUpdated,
}: UsageLimitEditorProps) {
const [inputValue, setInputValue] = useState(currentLimit.toString())
const [isSaving, setIsSaving] = useState(false)
const { updateUsageLimit } = useSubscriptionStore()
useEffect(() => {
setInputValue(currentLimit.toString())
}, [currentLimit])
const handleSubmit = async () => {
const newLimit = Number.parseInt(inputValue, 10)
if (Number.isNaN(newLimit) || newLimit < minimumLimit) {
setInputValue(currentLimit.toString())
return
}
if (newLimit === currentLimit) {
return
}
setIsSaving(true)
try {
const result = await updateUsageLimit(newLimit)
if (!result.success) {
throw new Error(result.error || 'Failed to update limit')
}
setInputValue(newLimit.toString())
onLimitUpdated?.(newLimit)
} catch (error) {
logger.error('Failed to update usage limit', { error })
setInputValue(currentLimit.toString())
} finally {
setIsSaving(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
}
return (
<div className='flex items-center'>
<span className='mr-1 text-sm'>$</span>
{canEdit ? (
<Input
type='number'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSubmit}
className='h-8 w-20 font-medium text-sm'
min={minimumLimit}
step='1'
disabled={isSaving}
/>
) : (
<span className='font-medium text-sm'>{currentLimit}</span>
)}
</div>
)
}

View File

@@ -0,0 +1,257 @@
import React, { useMemo } from 'react'
import { CheckCircle, ChevronDown, PlusCircle } 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 { cn } from '@/lib/utils'
type PermissionType = 'read' | 'write' | 'admin'
interface PermissionSelectorProps {
value: PermissionType
onChange: (value: PermissionType) => void
disabled?: boolean
className?: string
}
const PermissionSelector = React.memo<PermissionSelectorProps>(
({ value, onChange, disabled = false, className = '' }) => {
const permissionOptions = useMemo(
() => [
{ value: 'read' as PermissionType, label: 'Read', description: 'View only' },
{ value: 'write' as PermissionType, label: 'Write', description: 'Edit content' },
{ value: 'admin' as PermissionType, label: 'Admin', description: 'Full access' },
],
[]
)
return (
<div
className={cn(
'inline-flex overflow-hidden rounded-md border border-input bg-background shadow-sm',
className
)}
>
{permissionOptions.map((option, index) => (
<button
key={option.value}
type='button'
onClick={() => !disabled && onChange(option.value)}
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',
disabled && 'cursor-not-allowed opacity-50',
value === option.value
? 'z-10 bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:z-20 hover:bg-muted/50 hover:text-foreground',
index > 0 && 'border-input border-l'
)}
>
{option.label}
</button>
))}
</div>
)
}
)
PermissionSelector.displayName = 'PermissionSelector'
interface MemberInvitationCardProps {
inviteEmail: string
setInviteEmail: (email: string) => void
isInviting: boolean
showWorkspaceInvite: boolean
setShowWorkspaceInvite: (show: boolean) => void
selectedWorkspaces: Array<{ workspaceId: string; permission: string }>
userWorkspaces: any[]
onInviteMember: () => Promise<void>
onLoadUserWorkspaces: () => Promise<void>
onWorkspaceToggle: (workspaceId: string, permission: string) => void
inviteSuccess: boolean
}
function ButtonSkeleton() {
return (
<div className='h-4 w-4 animate-spin rounded-full border-2 border-muted border-t-primary' />
)
}
export function MemberInvitationCard({
inviteEmail,
setInviteEmail,
isInviting,
showWorkspaceInvite,
setShowWorkspaceInvite,
selectedWorkspaces,
userWorkspaces,
onInviteMember,
onLoadUserWorkspaces,
onWorkspaceToggle,
inviteSuccess,
}: MemberInvitationCardProps) {
const selectedCount = selectedWorkspaces.length
return (
<Card>
<CardHeader className='pb-4'>
<CardTitle className='text-base'>Invite Team Members</CardTitle>
<CardDescription>
Add new members to your team and optionally give them access to specific workspaces
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center gap-3'>
<div className='flex-1'>
<Input
placeholder='Enter email address'
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
disabled={isInviting}
className='w-full'
/>
</div>
<Button
variant='outline'
size='sm'
onClick={() => {
setShowWorkspaceInvite(!showWorkspaceInvite)
if (!showWorkspaceInvite) {
onLoadUserWorkspaces()
}
}}
disabled={isInviting}
className='shrink-0 gap-1'
>
{showWorkspaceInvite ? 'Hide' : 'Add'} Workspaces
{selectedCount > 0 && (
<Badge variant='secondary' className='ml-1 h-5 px-1.5 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='shrink-0 gap-2'
>
{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-sm'>Workspace Access</h5>
<Badge variant='outline' className='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>
{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-md 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
)
return (
<div
key={workspace.id}
className={cn(
'flex items-center justify-between rounded-md border bg-background p-3 transition-all',
isSelected
? 'border-primary/20 bg-primary/5'
: 'hover:border-border hover:bg-muted/50'
)}
>
<div className='flex items-center gap-3'>
<Checkbox
id={`workspace-${workspace.id}`}
checked={isSelected}
onCheckedChange={(checked) => {
if (checked) {
onWorkspaceToggle(workspace.id, 'read')
} else {
onWorkspaceToggle(workspace.id, '')
}
}}
disabled={isInviting}
/>
<Label
htmlFor={`workspace-${workspace.id}`}
className='cursor-pointer font-medium text-sm leading-none'
>
{workspace.name}
</Label>
{workspace.isOwner && (
<Badge variant='outline' className='text-xs'>
Owner
</Badge>
)}
</div>
{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>
)}
</div>
)
})}
</div>
)}
</div>
)}
{inviteSuccess && (
<Alert className='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>
)
}

View File

@@ -0,0 +1,136 @@
import { RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { OrganizationCreationDialog } from './organization-creation-dialog'
interface NoOrganizationViewProps {
hasTeamPlan: boolean
hasEnterprisePlan: boolean
orgName: string
setOrgName: (name: string) => void
orgSlug: string
setOrgSlug: (slug: string) => void
onOrgNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onCreateOrganization: () => Promise<void>
isCreatingOrg: boolean
error: string | null
createOrgDialogOpen: boolean
setCreateOrgDialogOpen: (open: boolean) => void
}
export function NoOrganizationView({
hasTeamPlan,
hasEnterprisePlan,
orgName,
setOrgName,
orgSlug,
setOrgSlug,
onOrgNameChange,
onCreateOrganization,
isCreatingOrg,
error,
createOrgDialogOpen,
setCreateOrgDialogOpen,
}: NoOrganizationViewProps) {
if (hasTeamPlan || hasEnterprisePlan) {
return (
<div className='space-y-6 p-6'>
<div className='space-y-6'>
<h3 className='font-medium text-lg'>Create Your Team Workspace</h3>
<div className='space-y-6 rounded-lg border p-6'>
<p className='text-muted-foreground text-sm'>
You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your
workspace to start collaborating with your team.
</p>
<div className='space-y-4'>
<div className='space-y-2'>
<label htmlFor='orgName' className='font-medium text-sm'>
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-sm'>
Team URL
</label>
<div className='flex items-center space-x-2'>
<div className='rounded-l-md bg-muted px-3 py-2 text-muted-foreground text-sm'>
simstudio.ai/team/
</div>
<Input
id='orgSlug'
value={orgSlug}
onChange={(e) => setOrgSlug(e.target.value)}
className='rounded-l-none'
/>
</div>
</div>
</div>
{error && (
<Alert variant='destructive'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className='flex justify-end space-x-2'>
<Button
onClick={onCreateOrganization}
disabled={!orgName || !orgSlug || isCreatingOrg}
>
{isCreatingOrg && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
Create Team Workspace
</Button>
</div>
</div>
</div>
<OrganizationCreationDialog
open={createOrgDialogOpen}
onOpenChange={setCreateOrgDialogOpen}
orgName={orgName}
onOrgNameChange={onOrgNameChange}
orgSlug={orgSlug}
onOrgSlugChange={setOrgSlug}
onCreateOrganization={onCreateOrganization}
isCreating={isCreatingOrg}
error={error}
/>
</div>
)
}
return (
<div className='space-y-6 p-6'>
<div className='space-y-6'>
<h3 className='font-medium text-lg'>No Team Workspace</h3>
<p className='text-muted-foreground text-sm'>
You don't have a team workspace yet. To collaborate with others, first upgrade to a team
or enterprise plan.
</p>
<Button
onClick={() => {
// Open the subscription tab
const event = new CustomEvent('open-settings', {
detail: { tab: 'subscription' },
})
window.dispatchEvent(event)
}}
>
Upgrade to Team Plan
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
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-sm'>
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-sm'>
Team URL
</label>
<div className='flex items-center space-x-2'>
<div className='rounded-l-md bg-muted px-3 py-2 text-muted-foreground text-sm'>
simstudio.ai/team/
</div>
<Input
value={orgSlug}
onChange={(e) => onOrgSlugChange(e.target.value)}
className='rounded-l-none'
/>
</div>
</div>
</div>
{error && (
<Alert variant='destructive'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)} disabled={isCreating}>
Cancel
</Button>
<Button onClick={onCreateOrganization} disabled={!orgName || !orgSlug || isCreating}>
{isCreating && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
Create Team Workspace
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,136 @@
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-6'>
{orgSettingsError && (
<Alert variant='destructive'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{orgSettingsError}</AlertDescription>
</Alert>
)}
{orgSettingsSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{orgSettingsSuccess}</AlertDescription>
</Alert>
)}
{!isAdminOrOwner && (
<Alert>
<AlertTitle>Read Only</AlertTitle>
<AlertDescription>
You need owner or admin permissions to modify team settings.
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className='text-base'>Basic Information</CardTitle>
<CardDescription>Update your team's basic information and branding</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<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>
<CardHeader>
<CardTitle className='text-base'>Team Information</CardTitle>
</CardHeader>
<CardContent className='space-y-2 text-sm'>
<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

@@ -0,0 +1,48 @@
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-md border'>
<h4 className='border-b p-4 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'>{invitation.email}</div>
<div className='text-muted-foreground text-sm'>Invitation pending</div>
</div>
</div>
</div>
<Button variant='outline' size='sm' onClick={() => onCancelInvitation(invitation.id)}>
<X className='h-4 w-4' />
</Button>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
interface RemoveMemberDialogProps {
open: boolean
memberName: string
shouldReduceSeats: boolean
onOpenChange: (open: boolean) => void
onShouldReduceSeatsChange: (shouldReduce: boolean) => void
onConfirmRemove: (shouldReduceSeats: boolean) => Promise<void>
onCancel: () => void
}
export function RemoveMemberDialog({
open,
memberName,
shouldReduceSeats,
onOpenChange,
onShouldReduceSeatsChange,
onConfirmRemove,
onCancel,
}: RemoveMemberDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Team Member</DialogTitle>
<DialogDescription>
Are you sure you want to remove {memberName} from the team?
</DialogDescription>
</DialogHeader>
<div className='py-4'>
<div className='flex items-center space-x-2'>
<input
type='checkbox'
id='reduce-seats'
className='rounded'
checked={shouldReduceSeats}
onChange={(e) => onShouldReduceSeatsChange(e.target.checked)}
/>
<label htmlFor='reduce-seats' className='text-sm'>
Also reduce seat count in my subscription
</label>
</div>
<p className='mt-1 text-muted-foreground text-xs'>
If selected, your team seat count will be reduced by 1, lowering your monthly billing.
</p>
</div>
<DialogFooter>
<Button variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button variant='destructive' onClick={() => onConfirmRemove(shouldReduceSeats)}>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,63 @@
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-md border'>
<h4 className='border-b p-4 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-md border'>
<h4 className='border-b p-4 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'>{member.user?.name || 'Unknown'}</div>
<div className='text-muted-foreground text-sm'>{member.user?.email}</div>
</div>
<div className='rounded-full bg-primary/10 px-3 py-1 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)}>
<UserX className='h-4 w-4' />
</Button>
)}
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
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 { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
type Subscription = {
id: string
plan: string
status: string
seats?: number
referenceId: string
cancelAtPeriodEnd?: boolean
periodEnd?: number | Date
trialEnd?: number | Date
metadata?: any
}
interface TeamSeatsOverviewProps {
subscriptionData: Subscription | null
isLoadingSubscription: boolean
usedSeats: number
isLoading: boolean
onConfirmTeamUpgrade: (seats: number) => Promise<void>
onReduceSeats: () => Promise<void>
onAddSeatDialog: () => void
}
function TeamSeatsSkeleton() {
return (
<div className='flex items-center space-x-2'>
<Skeleton className='h-4 w-4' />
<Skeleton className='h-4 w-32' />
</div>
)
}
export function TeamSeatsOverview({
subscriptionData,
isLoadingSubscription,
usedSeats,
isLoading,
onConfirmTeamUpgrade,
onReduceSeats,
onAddSeatDialog,
}: TeamSeatsOverviewProps) {
if (isLoadingSubscription) {
return (
<Card>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
</CardHeader>
<CardContent>
<TeamSeatsSkeleton />
</CardContent>
</Card>
)
}
if (!subscriptionData) {
return (
<Card>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
</CardHeader>
<CardContent>
<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'>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}
>
Set Up Team Subscription
</Button>
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
</CardHeader>
<CardContent>
<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-2xl'>{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-2xl'>{usedSeats}</p>
<p className='text-muted-foreground text-xs'>Used Seats</p>
</div>
<div className='space-y-1'>
<p className='font-bold text-2xl'>{(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-3' />
</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)}
</span>
</div>
<div className='mt-2 text-muted-foreground text-xs'>
Individual usage limits may vary. See Subscription tab for team totals.
</div>
{checkEnterprisePlan(subscriptionData) ? (
<div className='rounded-lg 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='flex-1'
>
Remove Seat
</Button>
<Button size='sm' onClick={onAddSeatDialog} disabled={isLoading} className='flex-1'>
Add Seat
</Button>
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,12 +1,13 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { client, useSubscription } from '@/lib/auth-client'
import { client } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { useOrganizationStore } from '@/stores/organization'
import { useGeneralStore } from '@/stores/settings/general/store'
import { Account } from './components/account/account'
import { ApiKeys } from './components/api-keys/api-keys'
@@ -37,14 +38,9 @@ type SettingsSection =
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const [isPro, setIsPro] = useState(false)
const [isTeam, setIsTeam] = useState(false)
const [isEnterprise, setIsEnterprise] = useState(false)
const [subscriptionData, setSubscriptionData] = useState<any>(null)
const [usageData, setUsageData] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const loadSettings = useGeneralStore((state) => state.loadSettings)
const subscription = useMemo(() => useSubscription(), [])
const { activeOrganization } = useOrganizationStore()
const hasLoadedInitialData = useRef(false)
useEffect(() => {
@@ -57,55 +53,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
try {
await loadSettings()
const proStatusResponse = await fetch('/api/user/subscription')
if (proStatusResponse.ok) {
const subData = await proStatusResponse.json()
setIsPro(subData.isPro)
setIsTeam(subData.isTeam)
setIsEnterprise(subData.isEnterprise)
}
const usageResponse = await fetch('/api/user/usage')
if (usageResponse.ok) {
const usageData = await usageResponse.json()
setUsageData(usageData)
}
try {
const result = await subscription.list()
if (isEnterprise) {
try {
const enterpriseResponse = await fetch('/api/user/subscription/enterprise')
if (enterpriseResponse.ok) {
const enterpriseData = await enterpriseResponse.json()
if (enterpriseData.subscription) {
setSubscriptionData(enterpriseData.subscription)
return
}
}
} catch (error) {
logger.error('Error fetching enterprise subscription', error)
}
}
if (result.data && result.data.length > 0) {
const activeSubscription = result.data.find(
(sub) =>
sub.status === 'active' &&
(sub.plan === 'team' || sub.plan === 'pro' || sub.plan === 'enterprise')
)
if (activeSubscription) {
setSubscriptionData(activeSubscription)
}
}
} catch (error) {
logger.error('Error fetching subscription information', error)
}
hasLoadedInitialData.current = true
} catch (error) {
logger.error('Error loading settings data:', error)
@@ -119,7 +66,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
} else {
hasLoadedInitialData.current = false
}
}, [open, loadSettings, subscription, activeSection, isEnterprise])
}, [open, loadSettings])
useEffect(() => {
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
@@ -136,8 +83,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const isSubscriptionEnabled = !!client.subscription
const showTeamManagement = isTeam || isEnterprise
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex h-[70vh] flex-col gap-0 p-0 sm:max-w-[800px]' hideCloseButton>
@@ -162,8 +107,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<SettingsNavigation
activeSection={activeSection}
onSectionChange={setActiveSection}
isTeam={isTeam}
isEnterprise={isEnterprise}
hasOrganization={!!activeOrganization?.id}
/>
</div>
@@ -186,22 +130,12 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
</div>
{isSubscriptionEnabled && (
<div className={cn('h-full', activeSection === 'subscription' ? 'block' : 'hidden')}>
<Subscription
onOpenChange={onOpenChange}
cachedIsPro={isPro}
cachedIsTeam={isTeam}
cachedIsEnterprise={isEnterprise}
cachedUsageData={usageData}
cachedSubscriptionData={subscriptionData}
isLoading={isLoading}
/>
</div>
)}
{showTeamManagement && (
<div className={cn('h-full', activeSection === 'team' ? 'block' : 'hidden')}>
<TeamManagement />
<Subscription onOpenChange={onOpenChange} />
</div>
)}
<div className={cn('h-full', activeSection === 'team' ? 'block' : 'hidden')}>
<TeamManagement />
</div>
<div className={cn('h-full', activeSection === 'privacy' ? 'block' : 'hidden')}>
<Privacy />
</div>

View File

@@ -32,6 +32,7 @@ import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import { useSidebarStore } from '@/stores/sidebar/store'
import { useSubscriptionStore } from '@/stores/subscription/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkspaceHeader')
@@ -243,7 +244,19 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
useSidebarStore()
const { data: sessionData, isPending } = useSession()
const [plan, setPlan] = useState('Free Plan')
const { getSubscriptionStatus } = useSubscriptionStore()
const subscription = getSubscriptionStatus()
const getPlanName = (subscription: ReturnType<typeof getSubscriptionStatus>) => {
if (subscription.isEnterprise) return 'Enterprise Plan'
if (subscription.isTeam) return 'Team Plan'
if (subscription.isPro) return 'Pro Plan'
return 'Free Plan'
}
const plan = getPlanName(subscription)
// Use client-side loading instead of isPending to avoid hydration mismatch
const [isClientLoading, setIsClientLoading] = useState(true)
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
@@ -273,16 +286,6 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
setIsClientLoading(false)
}, [])
const fetchSubscriptionStatus = useCallback(async (userId: string) => {
try {
const response = await fetch('/api/user/subscription')
const data = await response.json()
setPlan(data.isPro ? 'Pro Plan' : 'Free Plan')
} catch (err) {
logger.error('Error fetching subscription status:', err)
}
}, [])
const fetchWorkspaces = useCallback(async () => {
setIsWorkspacesLoading(true)
try {
@@ -327,10 +330,9 @@ export const WorkspaceHeader = React.memo<WorkspaceHeaderProps>(
useEffect(() => {
// Fetch subscription status if user is logged in
if (sessionData?.user?.id) {
fetchSubscriptionStatus(sessionData.user.id)
fetchWorkspaces()
}
}, [sessionData?.user?.id, fetchSubscriptionStatus, fetchWorkspaces])
}, [sessionData?.user?.id, fetchWorkspaces])
const switchWorkspace = useCallback(
async (workspace: Workspace) => {

View File

@@ -0,0 +1,278 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Img,
Preview,
Section,
Text,
} from '@react-email/components'
interface WorkspaceInvitation {
workspaceId: string
workspaceName: string
permission: 'admin' | 'write' | 'read'
}
interface BatchInvitationEmailProps {
inviterName: string
organizationName: string
organizationRole: 'admin' | 'member'
workspaceInvitations: WorkspaceInvitation[]
acceptUrl: string
}
const getPermissionLabel = (permission: string) => {
switch (permission) {
case 'admin':
return 'Admin (full access)'
case 'write':
return 'Editor (can edit workflows)'
case 'read':
return 'Viewer (read-only access)'
default:
return permission
}
}
const getRoleLabel = (role: string) => {
switch (role) {
case 'admin':
return 'Team Admin (can manage team and billing)'
case 'member':
return 'Team Member (billing access only)'
default:
return role
}
}
export const BatchInvitationEmail = ({
inviterName = 'Someone',
organizationName = 'the team',
organizationRole = 'member',
workspaceInvitations = [],
acceptUrl,
}: BatchInvitationEmailProps) => {
const hasWorkspaces = workspaceInvitations.length > 0
return (
<Html>
<Head />
<Preview>
You've been invited to join {organizationName}
{hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}
</Preview>
<Body style={main}>
<Container style={container}>
<Section style={logoContainer}>
<Img
src='https://simstudio.ai/logo.png'
width='120'
height='36'
alt='SimStudio'
style={logo}
/>
</Section>
<Heading style={h1}>You're invited to join {organizationName}!</Heading>
<Text style={text}>
<strong>{inviterName}</strong> has invited you to join{' '}
<strong>{organizationName}</strong> on SimStudio.
</Text>
{/* Organization Invitation Details */}
<Section style={invitationSection}>
<Heading style={h2}>Team Access</Heading>
<div style={roleCard}>
<Text style={roleTitle}>Team Role: {getRoleLabel(organizationRole)}</Text>
<Text style={roleDescription}>
{organizationRole === 'admin'
? "You'll be able to manage team members, billing, and workspace access."
: "You'll have access to shared team billing and can be invited to workspaces."}
</Text>
</div>
</Section>
{/* Workspace Invitations */}
{hasWorkspaces && (
<Section style={invitationSection}>
<Heading style={h2}>
Workspace Access ({workspaceInvitations.length} workspace
{workspaceInvitations.length !== 1 ? 's' : ''})
</Heading>
<Text style={text}>You're also being invited to the following workspaces:</Text>
{workspaceInvitations.map((ws, index) => (
<div key={ws.workspaceId} style={workspaceCard}>
<Text style={workspaceName}>{ws.workspaceName}</Text>
<Text style={workspacePermission}>{getPermissionLabel(ws.permission)}</Text>
</div>
))}
</Section>
)}
<Section style={buttonContainer}>
<Button style={button} href={acceptUrl}>
Accept Invitation
</Button>
</Section>
<Text style={text}>
By accepting this invitation, you'll join {organizationName}
{hasWorkspaces ? ` and gain access to ${workspaceInvitations.length} workspace(s)` : ''}
.
</Text>
<Hr style={hr} />
<Text style={footer}>
If you have any questions, you can reach out to {inviterName} directly or contact our
support team.
</Text>
<Text style={footer}>
This invitation will expire in 7 days. If you didn't expect this invitation, you can
safely ignore this email.
</Text>
</Container>
</Body>
</Html>
)
}
export default BatchInvitationEmail
// Styles
const main = {
backgroundColor: '#f6f9fc',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
}
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '20px 0 48px',
marginBottom: '64px',
}
const logoContainer = {
margin: '32px 0',
textAlign: 'center' as const,
}
const logo = {
margin: '0 auto',
}
const h1 = {
color: '#333',
fontSize: '24px',
fontWeight: 'bold',
margin: '40px 0',
padding: '0',
textAlign: 'center' as const,
}
const h2 = {
color: '#333',
fontSize: '18px',
fontWeight: 'bold',
margin: '24px 0 16px 0',
padding: '0',
}
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '26px',
margin: '16px 0',
padding: '0 40px',
}
const invitationSection = {
margin: '32px 0',
padding: '0 40px',
}
const roleCard = {
backgroundColor: '#f8f9fa',
border: '1px solid #e9ecef',
borderRadius: '8px',
padding: '16px',
margin: '16px 0',
}
const roleTitle = {
color: '#333',
fontSize: '16px',
fontWeight: 'bold',
margin: '0 0 8px 0',
}
const roleDescription = {
color: '#6c757d',
fontSize: '14px',
lineHeight: '20px',
margin: '0',
}
const workspaceCard = {
backgroundColor: '#f8f9fa',
border: '1px solid #e9ecef',
borderRadius: '6px',
padding: '12px 16px',
margin: '8px 0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}
const workspaceName = {
color: '#333',
fontSize: '15px',
fontWeight: '500',
margin: '0',
}
const workspacePermission = {
color: '#6c757d',
fontSize: '13px',
margin: '0',
}
const buttonContainer = {
margin: '32px 0',
textAlign: 'center' as const,
}
const button = {
backgroundColor: '#007bff',
borderRadius: '6px',
color: '#fff',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'inline-block',
padding: '12px 24px',
margin: '0 auto',
}
const hr = {
borderColor: '#e9ecef',
margin: '32px 0',
}
const footer = {
color: '#6c757d',
fontSize: '14px',
lineHeight: '20px',
margin: '8px 0',
padding: '0 40px',
}

View File

@@ -1,10 +1,8 @@
import { render } from '@react-email/components'
import { generateUnsubscribeToken } from '@/lib/email/unsubscribe'
import { BatchInvitationEmail } from './batch-invitation-email'
import { InvitationEmail } from './invitation-email'
import { OTPVerificationEmail } from './otp-verification-email'
import { ResetPasswordEmail } from './reset-password-email'
import { WaitlistApprovalEmail } from './waitlist-approval-email'
import { WaitlistConfirmationEmail } from './waitlist-confirmation-email'
export async function renderOTPEmail(
otp: string,
@@ -41,17 +39,28 @@ export async function renderInvitationEmail(
)
}
export async function renderWaitlistConfirmationEmail(email: string): Promise<string> {
const unsubscribeToken = generateUnsubscribeToken(email, 'marketing')
return await render(WaitlistConfirmationEmail({ email, unsubscribeToken }))
interface WorkspaceInvitation {
workspaceId: string
workspaceName: string
permission: 'admin' | 'write' | 'read'
}
export async function renderWaitlistApprovalEmail(
email: string,
signupUrl: string
export async function renderBatchInvitationEmail(
inviterName: string,
organizationName: string,
organizationRole: 'admin' | 'member',
workspaceInvitations: WorkspaceInvitation[],
acceptUrl: string
): Promise<string> {
const unsubscribeToken = generateUnsubscribeToken(email, 'updates')
return await render(WaitlistApprovalEmail({ email, signupUrl, unsubscribeToken }))
return await render(
BatchInvitationEmail({
inviterName,
organizationName,
organizationRole,
workspaceInvitations,
acceptUrl,
})
)
}
export function getEmailSubject(
@@ -60,9 +69,8 @@ export function getEmailSubject(
| 'email-verification'
| 'forget-password'
| 'reset-password'
| 'waitlist-confirmation'
| 'waitlist-approval'
| 'invitation'
| 'batch-invitation'
): string {
switch (type) {
case 'sign-in':
@@ -73,12 +81,10 @@ export function getEmailSubject(
return 'Reset your Sim Studio password'
case 'reset-password':
return 'Reset your Sim Studio password'
case 'waitlist-confirmation':
return 'Welcome to the Sim Studio Waitlist'
case 'waitlist-approval':
return "You've Been Approved to Join Sim Studio!"
case 'invitation':
return "You've been invited to join a team on Sim Studio"
case 'batch-invitation':
return "You've been invited to join a team and workspaces on Sim Studio"
default:
return 'Sim Studio'
}

View File

@@ -1,91 +0,0 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { env } from '@/lib/env'
import { baseStyles } from './base-styles'
import { EmailFooter } from './footer'
interface WaitlistApprovalEmailProps {
email: string
signupUrl: string
unsubscribeToken?: string
}
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
export const WaitlistApprovalEmail = ({
email,
signupUrl,
unsubscribeToken,
}: WaitlistApprovalEmailProps) => {
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>You've Been Approved to Join Sim Studio!</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={`${baseUrl}/static/sim.png`}
width='114'
alt='Sim Studio'
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Great news!</Text>
<Text style={baseStyles.paragraph}>
You've been approved to join Sim Studio! We're excited to have you as part of our
community of developers building, testing, and optimizing AI workflows.
</Text>
<Text style={baseStyles.paragraph}>
Your email ({email}) has been approved. Click the button below to create your account
and start using Sim Studio today:
</Text>
<Link href={signupUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Create Your Account</Text>
</Link>
<Text style={baseStyles.paragraph}>
This approval link will expire in 7 days. If you have any questions or need
assistance, feel free to reach out to our support team.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Studio Team
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} unsubscribe={{ unsubscribeToken, email }} />
</Body>
</Html>
)
}
export default WaitlistApprovalEmail

View File

@@ -1,89 +0,0 @@
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { env } from '@/lib/env'
import { baseStyles } from './base-styles'
import { EmailFooter } from './footer'
interface WaitlistConfirmationEmailProps {
email: string
unsubscribeToken?: string
}
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
const typeformLink = 'https://form.typeform.com/to/jqCO12pF'
export const WaitlistConfirmationEmail = ({
email,
unsubscribeToken,
}: WaitlistConfirmationEmailProps) => {
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Welcome to the Sim Studio Waitlist!</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={`${baseUrl}/static/sim.png`}
width='114'
alt='Sim Studio'
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Welcome to the Sim Studio Waitlist!</Text>
<Text style={baseStyles.paragraph}>
Thank you for your interest in Sim Studio. We've added your email ({email}) to our
waitlist and will notify you as soon as you're granted access.
</Text>
<Text style={baseStyles.paragraph}>
<strong>Want to get access sooner?</strong> Tell us about your use case! Schedule a
15-minute call with our team to discuss how you plan to use Sim Studio.
</Text>
<Link href={typeformLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Schedule a Call</Text>
</Link>
<Text style={baseStyles.paragraph}>
We're excited to help you build, test, and optimize your agentic workflows.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Studio Team
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} unsubscribe={{ unsubscribeToken, email }} />
</Body>
</Html>
)
}
export default WaitlistConfirmationEmail

View File

@@ -0,0 +1,228 @@
import { useCallback, useEffect, useState } from 'react'
import type { SubscriptionFeatures } from '@/lib/billing/types'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('useSubscriptionState')
interface UsageData {
current: number
limit: number
percentUsed: number
isWarning: boolean
isExceeded: boolean
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
lastPeriodCost: number
}
interface SubscriptionState {
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
plan: string
status: string | null
seats: number | null
metadata: any | null
features: SubscriptionFeatures
usage: UsageData
}
/**
* Consolidated hook for subscription state management
* Combines subscription status, features, and usage data
*/
export function useSubscriptionState() {
const [data, setData] = useState<SubscriptionState | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchSubscriptionState = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const response = await fetch('/api/billing?context=user')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
const subscriptionData = result.data
setData(subscriptionData)
} catch (error) {
const err = error instanceof Error ? error : new Error('Failed to fetch subscription state')
logger.error('Failed to fetch subscription state', { error })
setError(err)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchSubscriptionState()
}, [fetchSubscriptionState])
const refetch = useCallback(() => {
return fetchSubscriptionState()
}, [fetchSubscriptionState])
return {
subscription: {
isPaid: data?.isPaid ?? false,
isPro: data?.isPro ?? false,
isTeam: data?.isTeam ?? false,
isEnterprise: data?.isEnterprise ?? false,
isFree: !(data?.isPaid ?? false),
plan: data?.plan ?? 'free',
status: data?.status,
seats: data?.seats,
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 ?? 5,
percentUsed: data?.usage?.percentUsed ?? 0,
isWarning: data?.usage?.isWarning ?? false,
isExceeded: data?.usage?.isExceeded ?? false,
billingPeriodStart: data?.usage?.billingPeriodStart
? new Date(data.usage.billingPeriodStart)
: null,
billingPeriodEnd: data?.usage?.billingPeriodEnd
? new Date(data.usage.billingPeriodEnd)
: null,
lastPeriodCost: data?.usage?.lastPeriodCost ?? 0,
},
isLoading,
error,
refetch,
hasFeature: (feature: keyof SubscriptionFeatures) => {
return data?.features?.[feature] ?? false
},
isAtLeastPro: () => {
return data?.isPro || data?.isTeam || data?.isEnterprise || false
},
isAtLeastTeam: () => {
return data?.isTeam || data?.isEnterprise || false
},
canUpgrade: () => {
return data?.plan === 'free' || data?.plan === 'pro'
},
getBillingStatus: () => {
const usage = data?.usage
if (!usage) return 'unknown'
if (usage.isExceeded) return 'exceeded'
if (usage.isWarning) return 'warning'
return 'ok'
},
getRemainingBudget: () => {
const usage = data?.usage
if (!usage) return 0
return Math.max(0, usage.limit - usage.current)
},
getDaysRemainingInPeriod: () => {
const usage = data?.usage
if (!usage?.billingPeriodEnd) return null
const now = new Date()
const endDate = new Date(usage.billingPeriodEnd)
const diffTime = endDate.getTime() - now.getTime()
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return Math.max(0, diffDays)
},
}
}
/**
* Hook for usage limit information with editing capabilities
*/
export function useUsageLimit() {
const [data, setData] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchUsageLimit = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const response = await fetch('/api/usage-limits?context=user')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const limitData = await response.json()
setData(limitData)
} catch (error) {
const err = error instanceof Error ? error : new Error('Failed to fetch usage limit')
logger.error('Failed to fetch usage limit', { error })
setError(err)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchUsageLimit()
}, [fetchUsageLimit])
const refetch = useCallback(() => {
return fetchUsageLimit()
}, [fetchUsageLimit])
const updateLimit = async (newLimit: number) => {
try {
const response = await fetch('/api/usage-limits?context=user', {
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 usage limit')
}
await refetch()
return { success: true }
} catch (error) {
logger.error('Failed to update usage limit', { error, newLimit })
throw error
}
}
return {
currentLimit: data?.currentLimit ?? 5,
canEdit: data?.canEdit ?? false,
minimumLimit: data?.minimumLimit ?? 5,
plan: data?.plan ?? 'free',
setBy: data?.setBy,
updatedAt: data?.updatedAt ? new Date(data.updatedAt) : null,
updateLimit,
isLoading,
error,
refetch,
}
}

View File

@@ -1,63 +0,0 @@
import { useEffect, useState } from 'react'
interface UserSubscription {
isPaid: boolean
isLoading: boolean
plan: string | null
error: Error | null
isEnterprise: boolean
}
export function useUserSubscription(): UserSubscription {
const [subscription, setSubscription] = useState<UserSubscription>({
isPaid: false,
isLoading: true,
plan: null,
error: null,
isEnterprise: false,
})
useEffect(() => {
let mounted = true
const fetchSubscription = async () => {
try {
const response = await fetch('/api/user/subscription')
if (!response.ok) {
throw new Error('Failed to fetch subscription data')
}
const data = await response.json()
if (mounted) {
setSubscription({
isPaid: data.isPaid,
isLoading: false,
plan: data.plan,
error: null,
isEnterprise: !!data.isEnterprise,
})
}
} catch (error) {
if (mounted) {
setSubscription({
isPaid: false,
isLoading: false,
plan: null,
error: error instanceof Error ? error : new Error('Unknown error'),
isEnterprise: false,
})
}
}
}
fetchSubscription()
return () => {
mounted = false
}
}, [])
return subscription
}

View File

@@ -46,7 +46,6 @@ if (validStripeKey) {
// If there is no resend key, it might be a local dev environment
// In that case, we don't want to send emails and just log them
const validResendAPIKEY =
env.RESEND_API_KEY && env.RESEND_API_KEY.trim() !== '' && env.RESEND_API_KEY !== 'placeholder'
@@ -1027,6 +1026,18 @@ export const auth = betterAuth({
customerId: customer.id,
userId: user.id,
})
// Initialize usage limits for new user
try {
const { initializeUserUsageLimit } = await import('./billing')
await initializeUserUsageLimit(user.id)
logger.info('Usage limits initialized for new user', { userId: user.id })
} catch (error) {
logger.error('Failed to initialize usage limits for new user', {
userId: user.id,
error,
})
}
},
subscription: {
enabled: true,
@@ -1125,6 +1136,134 @@ export const auth = betterAuth({
plan: subscription.plan,
status: subscription.status,
})
// Auto-create organization for team plan purchases
if (subscription.plan === 'team') {
try {
// Get the user who purchased the subscription
const user = await db
.select()
.from(schema.user)
.where(eq(schema.user.id, subscription.referenceId))
.limit(1)
if (user.length > 0) {
const currentUser = user[0]
// Create organization for the team
const orgId = `org_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
const orgSlug = `${currentUser.name?.toLowerCase().replace(/\s+/g, '-') || 'team'}-${Date.now()}`
// Create a separate Stripe customer for the organization
let orgStripeCustomerId: string | null = null
if (stripeClient) {
try {
const orgStripeCustomer = await stripeClient.customers.create({
name: `${currentUser.name || 'User'}'s Team`,
email: currentUser.email,
metadata: {
organizationId: orgId,
type: 'organization',
},
})
orgStripeCustomerId = orgStripeCustomer.id
} catch (error) {
logger.error('Failed to create Stripe customer for organization', {
organizationId: orgId,
error,
})
// Continue without Stripe customer - can be created later
}
}
const newOrg = await db
.insert(schema.organization)
.values({
id: orgId,
name: `${currentUser.name || 'User'}'s Team`,
slug: orgSlug,
metadata: orgStripeCustomerId
? { stripeCustomerId: orgStripeCustomerId }
: null,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
// Add the user as owner of the organization
await db.insert(schema.member).values({
id: `member_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`,
userId: currentUser.id,
organizationId: orgId,
role: 'owner',
createdAt: new Date(),
})
// Update the subscription to reference the organization instead of the user
await db
.update(schema.subscription)
.set({ referenceId: orgId })
.where(eq(schema.subscription.id, subscription.id))
// Update the session to set the new organization as active
await db
.update(schema.session)
.set({ activeOrganizationId: orgId })
.where(eq(schema.session.userId, currentUser.id))
logger.info('Auto-created organization for team subscription', {
organizationId: orgId,
userId: currentUser.id,
subscriptionId: subscription.id,
orgName: `${currentUser.name || 'User'}'s Team`,
})
// Update referenceId for usage limit sync
subscription.referenceId = orgId
}
} catch (error) {
logger.error('Failed to auto-create organization for team subscription', {
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
error,
})
}
}
// Sync usage limits and initialize billing period for the user/organization
try {
const { syncUsageLimitsFromSubscription } = await import('./billing')
const { initializeBillingPeriod } = await import('./billing/core/billing-periods')
await syncUsageLimitsFromSubscription(subscription.referenceId)
logger.info('Usage limits synced after subscription creation', {
referenceId: subscription.referenceId,
})
// 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,
@@ -1137,6 +1276,20 @@ export const auth = betterAuth({
subscriptionId: subscription.id,
status: subscription.status,
})
// Sync usage limits for the user/organization
try {
const { syncUsageLimitsFromSubscription } = await import('./billing')
await syncUsageLimitsFromSubscription(subscription.referenceId)
logger.info('Usage limits synced after subscription update', {
referenceId: subscription.referenceId,
})
} catch (error) {
logger.error('Failed to sync usage limits after subscription update', {
referenceId: subscription.referenceId,
error,
})
}
},
onSubscriptionDeleted: async ({
event,

View File

@@ -1,5 +1,9 @@
import { jwtVerify, SignJWT } from 'jose'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('CronAuth')
// Create a secret key for JWT signing
const getJwtSecret = () => {
@@ -45,3 +49,26 @@ export async function verifyInternalToken(token: string): Promise<boolean> {
return false
}
}
/**
* Verify CRON authentication for scheduled API endpoints
* Returns null if authorized, or a NextResponse with error if unauthorized
*/
export function verifyCronAuth(request: NextRequest, context?: string): NextResponse | null {
const authHeader = request.headers.get('authorization')
const expectedAuth = `Bearer ${env.CRON_SECRET}`
if (authHeader !== expectedAuth) {
const contextInfo = context ? ` for ${context}` : ''
logger.warn(`Unauthorized CRON access attempt${contextInfo}`, {
providedAuth: authHeader,
ip: request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? 'unknown',
userAgent: request.headers.get('user-agent') ?? 'unknown',
context,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return null
}

View File

@@ -1,11 +1,9 @@
import { eq } from 'drizzle-orm'
import { isProd } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { userStats } from '@/db/schema'
import { env } from './env'
import { createLogger } from './logs/console-logger'
import { getHighestPrioritySubscription } from './subscription/subscription'
import { calculateUsageLimit } from './subscription/utils'
import { getUserUsageLimit } from '../core/usage'
const logger = createLogger('UsageMonitor')
@@ -31,7 +29,11 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
// Get actual usage from the database for display purposes
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
const currentUsage =
statsRecords.length > 0 ? Number.parseFloat(statsRecords[0].totalCost.toString()) : 0
statsRecords.length > 0
? Number.parseFloat(
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
)
: 0
return {
percentUsed: Math.min(Math.round((currentUsage / 1000) * 100), 100),
@@ -42,23 +44,9 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
}
}
// Determine subscription (single source of truth)
const activeSubscription = await getHighestPrioritySubscription(userId)
let limit = 0
if (activeSubscription) {
limit = calculateUsageLimit(activeSubscription)
logger.info('Using calculated subscription limit', {
userId,
plan: activeSubscription.plan,
seats: activeSubscription.seats || 1,
limit,
})
} else {
// Free tier limit
limit = env.FREE_TIER_COST_LIMIT ?? 5
logger.info('Using free tier limit', { userId, limit })
}
// Get usage limit from user_stats (new method)
const limit = await getUserUsageLimit(userId)
logger.info('Using stored usage limit', { userId, limit })
// Get actual usage from the database
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
@@ -76,8 +64,10 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
}
}
// Get the current cost from the user stats
const currentUsage = Number.parseFloat(statsRecords[0].totalCost.toString())
// Get the current period cost from the user stats (use currentPeriodCost if available, fallback to totalCost)
const currentUsage = Number.parseFloat(
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
)
// Calculate percentage used
const percentUsed = Math.min(Math.round((currentUsage / limit) * 100), 100)

View File

@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { calculateBillingPeriod, calculateNextBillingPeriod } from './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

@@ -0,0 +1,281 @@
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
}
// Update user stats with billing period info
await db
.update(userStats)
.set({
billingPeriodStart: start,
billingPeriodEnd: end,
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
await db
.update(userStats)
.set({
lastPeriodCost: currentPeriodCost, // Archive previous period
currentPeriodCost: '0', // Reset to zero for new period
billingPeriodStart: newPeriodStart,
billingPeriodEnd: newPeriodEnd,
})
.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

@@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getPlanPricing, getUsersAndOrganizationsForOverageBilling } from './billing'
import { calculateBillingPeriod, calculateNextBillingPeriod } from './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('../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())
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
import { and, eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { member, organization, user, userStats } from '@/db/schema'
import { getPlanPricing } from './billing'
import { getHighestPrioritySubscription } from './subscription'
const logger = createLogger('OrganizationBilling')
interface OrganizationUsageData {
organizationId: string
organizationName: string
subscriptionPlan: string
subscriptionStatus: string
totalSeats: number
usedSeats: number
totalCurrentUsage: number
totalUsageLimit: number
averageUsagePerMember: number
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
members: MemberUsageData[]
}
interface MemberUsageData {
userId: string
userName: string
userEmail: string
currentUsage: number
usageLimit: number
percentUsed: number
isOverLimit: boolean
role: string
joinedAt: Date
lastActive: Date | null
}
/**
* Get comprehensive organization billing and usage data
*/
export async function getOrganizationBillingData(
organizationId: string
): Promise<OrganizationUsageData | null> {
try {
// Get organization info
const orgRecord = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (orgRecord.length === 0) {
logger.warn('Organization not found', { organizationId })
return null
}
const organizationData = orgRecord[0]
// Get organization subscription
const subscription = await getHighestPrioritySubscription(organizationId)
if (!subscription) {
logger.warn('No subscription found for organization', { organizationId })
return null
}
// Get all organization members with their usage data
const membersWithUsage = await db
.select({
userId: member.userId,
userName: user.name,
userEmail: user.email,
role: member.role,
joinedAt: member.createdAt,
// User stats fields
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
billingPeriodStart: userStats.billingPeriodStart,
billingPeriodEnd: userStats.billingPeriodEnd,
lastActive: userStats.lastActive,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
// Process member data
const members: MemberUsageData[] = membersWithUsage.map((memberRecord) => {
const currentUsage = Number(memberRecord.currentPeriodCost || 0)
const usageLimit = Number(memberRecord.currentUsageLimit || 5)
const percentUsed = usageLimit > 0 ? (currentUsage / usageLimit) * 100 : 0
return {
userId: memberRecord.userId,
userName: memberRecord.userName,
userEmail: memberRecord.userEmail,
currentUsage,
usageLimit,
percentUsed: Math.round(percentUsed * 100) / 100,
isOverLimit: currentUsage > usageLimit,
role: memberRecord.role,
joinedAt: memberRecord.joinedAt,
lastActive: memberRecord.lastActive,
}
})
// Calculate aggregated statistics
const totalCurrentUsage = members.reduce((sum, member) => sum + member.currentUsage, 0)
// Get per-seat pricing for the plan
const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan, subscription)
const licensedSeats = subscription.seats || members.length
// Validate seat capacity - warn if members exceed licensed seats
if (subscription.seats && members.length > subscription.seats) {
logger.warn('Organization has more members than licensed seats', {
organizationId,
licensedSeats: subscription.seats,
actualMembers: members.length,
plan: subscription.plan,
})
}
// Billing is based on licensed seats, not actual member count
// This ensures organizations pay for their seat capacity regardless of utilization
const seatsCount = licensedSeats
const minimumBillingAmount = seatsCount * 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
const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0
// Get billing period from first member (should be consistent across org)
const firstMember = membersWithUsage[0]
const billingPeriodStart = firstMember?.billingPeriodStart || null
const billingPeriodEnd = firstMember?.billingPeriodEnd || null
return {
organizationId,
organizationName: organizationData.name,
subscriptionPlan: subscription.plan,
subscriptionStatus: subscription.status || 'active',
totalSeats: subscription.seats || 1,
usedSeats: members.length,
totalCurrentUsage: Math.round(totalCurrentUsage * 100) / 100,
totalUsageLimit: Math.round(totalUsageLimit * 100) / 100,
averageUsagePerMember: Math.round(averageUsagePerMember * 100) / 100,
billingPeriodStart,
billingPeriodEnd,
members: members.sort((a, b) => b.currentUsage - a.currentUsage), // Sort by usage desc
}
} catch (error) {
logger.error('Failed to get organization billing data', { organizationId, error })
throw error
}
}
/**
* Update usage limit for a specific organization member
*/
export async function updateMemberUsageLimit(
organizationId: string,
memberId: string,
newLimit: number,
adminUserId: string
): Promise<void> {
try {
// Verify admin has permission to modify limits
const adminMemberRecord = await db
.select()
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, adminUserId)))
.limit(1)
if (adminMemberRecord.length === 0 || !['owner', 'admin'].includes(adminMemberRecord[0].role)) {
throw new Error('Insufficient permissions to modify usage limits')
}
// 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
const subscription = await getHighestPrioritySubscription(organizationId)
if (!subscription) {
throw new Error('No active subscription found')
}
// Validate minimum limit based on plan
const planLimits = {
free: 5,
pro: 20,
team: 40,
enterprise: 100, // Default, can be overridden by metadata
}
let minimumLimit = planLimits[subscription.plan as keyof typeof planLimits] || 5
// 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 })
}
}
if (newLimit < minimumLimit) {
throw new Error(`Usage limit cannot be below $${minimumLimit} for ${subscription.plan} plan`)
}
// Update the member's usage limit
await db
.update(userStats)
.set({
currentUsageLimit: newLimit.toString(),
usageLimitSetBy: adminUserId,
usageLimitUpdatedAt: new Date(),
})
.where(eq(userStats.userId, memberId))
logger.info('Updated member usage limit', {
organizationId,
memberId,
newLimit,
adminUserId,
})
} catch (error) {
logger.error('Failed to update member usage limit', {
organizationId,
memberId,
newLimit,
adminUserId,
error,
})
throw error
}
}
/**
* Get organization billing summary for admin dashboard
*/
export async function getOrganizationBillingSummary(organizationId: string) {
try {
const billingData = await getOrganizationBillingData(organizationId)
if (!billingData) {
return null
}
// Calculate additional metrics
const membersOverLimit = billingData.members.filter((m) => m.isOverLimit).length
const membersNearLimit = billingData.members.filter(
(m) => !m.isOverLimit && m.percentUsed >= 80
).length
const topUsers = billingData.members.slice(0, 5).map((m) => ({
name: m.userName,
usage: m.currentUsage,
limit: m.usageLimit,
percentUsed: m.percentUsed,
}))
return {
organization: {
id: billingData.organizationId,
name: billingData.organizationName,
plan: billingData.subscriptionPlan,
status: billingData.subscriptionStatus,
},
usage: {
total: billingData.totalCurrentUsage,
limit: billingData.totalUsageLimit,
average: billingData.averageUsagePerMember,
percentUsed:
billingData.totalUsageLimit > 0
? (billingData.totalCurrentUsage / billingData.totalUsageLimit) * 100
: 0,
},
seats: {
total: billingData.totalSeats,
used: billingData.usedSeats,
available: billingData.totalSeats - billingData.usedSeats,
},
alerts: {
membersOverLimit,
membersNearLimit,
},
billingPeriod: {
start: billingData.billingPeriodStart,
end: billingData.billingPeriodEnd,
},
topUsers,
}
} catch (error) {
logger.error('Failed to get organization billing summary', { organizationId, error })
throw error
}
}

View File

@@ -0,0 +1,385 @@
import { and, eq, inArray } from 'drizzle-orm'
import { isProd } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { member, subscription, userStats } from '@/db/schema'
import { client } from '../../auth-client'
import {
calculateDefaultUsageLimit,
checkEnterprisePlan,
checkProPlan,
checkTeamPlan,
} from '../subscriptions/utils'
import type { UserSubscriptionState } from '../types'
const logger = createLogger('SubscriptionCore')
/**
* Core subscription management - single source of truth
* Consolidates logic from both lib/subscription.ts and lib/subscription/subscription.ts
*/
/**
* Get the highest priority active subscription for a user
* Priority: Enterprise > Team > Pro > Free
*/
export async function getHighestPrioritySubscription(userId: string) {
try {
// Get direct subscriptions
const personalSubs = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
// Get organization memberships
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
// Get organization subscriptions
let orgSubs: any[] = []
if (orgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
}
const allSubs = [...personalSubs, ...orgSubs]
if (allSubs.length === 0) return null
// Return highest priority subscription
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
if (enterpriseSub) return enterpriseSub
const teamSub = allSubs.find((s) => checkTeamPlan(s))
if (teamSub) return teamSub
const proSub = allSubs.find((s) => checkProPlan(s))
if (proSub) return proSub
return null
} catch (error) {
logger.error('Error getting highest priority subscription', { error, userId })
return null
}
}
/**
* Check if user is on Pro plan (direct or via organization)
*/
export async function isProPlan(userId: string): Promise<boolean> {
try {
// In development, enable Pro features for easier testing
if (!isProd) {
return true
}
const subscription = await getHighestPrioritySubscription(userId)
const isPro =
subscription &&
(checkProPlan(subscription) ||
checkTeamPlan(subscription) ||
checkEnterprisePlan(subscription))
if (isPro) {
logger.info('User has pro-level plan', { userId, plan: subscription.plan })
}
return !!isPro
} catch (error) {
logger.error('Error checking pro plan status', { error, userId })
return false
}
}
/**
* Check if user is on Team plan (direct or via organization)
*/
export async function isTeamPlan(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const subscription = await getHighestPrioritySubscription(userId)
const isTeam =
subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription))
if (isTeam) {
logger.info('User has team-level plan', { userId, plan: subscription.plan })
}
return !!isTeam
} catch (error) {
logger.error('Error checking team plan status', { error, userId })
return false
}
}
/**
* Check if user is on Enterprise plan (direct or via organization)
*/
export async function isEnterprisePlan(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const subscription = await getHighestPrioritySubscription(userId)
const isEnterprise = subscription && checkEnterprisePlan(subscription)
if (isEnterprise) {
logger.info('User has enterprise plan', { userId, plan: subscription.plan })
}
return !!isEnterprise
} catch (error) {
logger.error('Error checking enterprise plan status', { error, userId })
return false
}
}
/**
* Check if user has exceeded their cost limit based on current period usage
*/
export async function hasExceededCostLimit(userId: string): Promise<boolean> {
try {
if (!isProd) {
return false
}
const subscription = await getHighestPrioritySubscription(userId)
// Calculate usage limit
let limit = 5 // Default free tier limit
if (subscription) {
limit = calculateDefaultUsageLimit(subscription)
logger.info('Using subscription-based limit', {
userId,
plan: subscription.plan,
seats: subscription.seats || 1,
limit,
})
} else {
logger.info('Using free tier limit', { userId, limit })
}
// Get user stats to check current period usage
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
if (statsRecords.length === 0) {
return false
}
// Use current period cost instead of total cost for accurate billing period tracking
const currentCost = Number.parseFloat(
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
)
logger.info('Checking cost limit', { userId, currentCost, limit })
return currentCost >= limit
} catch (error) {
logger.error('Error checking cost limit', { error, userId })
return false // Be conservative in case of error
}
}
/**
* 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
}
}
/**
* Get comprehensive subscription state for a user
* Single function to get all subscription information
*/
export async function getUserSubscriptionState(userId: string): Promise<UserSubscriptionState> {
try {
// Get subscription and user stats in parallel to minimize DB calls
const [subscription, statsRecords] = await Promise.all([
getHighestPrioritySubscription(userId),
db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1),
])
// Determine plan types based on subscription (avoid redundant DB calls)
const isPro =
!isProd ||
(subscription &&
(checkProPlan(subscription) ||
checkTeamPlan(subscription) ||
checkEnterprisePlan(subscription)))
const isTeam =
!isProd ||
(subscription && (checkTeamPlan(subscription) || checkEnterprisePlan(subscription)))
const isEnterprise = !isProd || (subscription && checkEnterprisePlan(subscription))
const isFree = !isPro && !isTeam && !isEnterprise
// Determine plan name
let planName = 'free'
if (isEnterprise) planName = 'enterprise'
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 = 5 // Default free tier limit
if (subscription) {
limit = calculateDefaultUsageLimit(subscription)
}
const currentCost = Number.parseFloat(
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
)
hasExceededLimit = currentCost >= limit
}
return {
isPro,
isTeam,
isEnterprise,
isFree,
highestPrioritySubscription: subscription,
features: {
sharingEnabled,
multiplayerEnabled,
workspaceCollaborationEnabled,
},
hasExceededLimit,
planName,
}
} catch (error) {
logger.error('Error getting user subscription state', { error, userId })
// Return safe defaults in case of error
return {
isPro: false,
isTeam: false,
isEnterprise: false,
isFree: true,
highestPrioritySubscription: null,
features: {
sharingEnabled: false,
multiplayerEnabled: false,
workspaceCollaborationEnabled: false,
},
hasExceededLimit: false,
planName: 'free',
}
}
}

View File

@@ -0,0 +1,511 @@
import { and, eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { member, user, userStats } from '@/db/schema'
import { calculateDefaultUsageLimit, canEditUsageLimit } from '../subscriptions/utils'
import type { BillingData, UsageData, UsageLimitInfo } from '../types'
import { getHighestPrioritySubscription } from './subscription'
const logger = createLogger('UsageManagement')
/**
* Consolidated usage management module
* Handles user usage tracking, limits, and monitoring
*/
/**
* 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)
if (userStatsData.length === 0) {
// Initialize user stats if they don't exist
await initializeUserUsageLimit(userId)
return {
currentUsage: 0,
limit: 5,
percentUsed: 0,
isWarning: false,
isExceeded: false,
billingPeriodStart: null,
billingPeriodEnd: null,
lastPeriodCost: 0,
}
}
const stats = userStatsData[0]
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
const isWarning = percentUsed >= 80
const isExceeded = currentUsage >= limit
return {
currentUsage,
limit,
percentUsed,
isWarning,
isExceeded,
billingPeriodStart: stats.billingPeriodStart,
billingPeriodEnd: stats.billingPeriodEnd,
lastPeriodCost: Number.parseFloat(stats.lastPeriodCost?.toString() || '0'),
}
} catch (error) {
logger.error('Failed to get user usage data', { userId, error })
throw error
}
}
/**
* Get usage limit information for a user
*/
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 = 5
} 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)
if (userStatsRecord.length === 0) {
await initializeUserUsageLimit(userId)
return {
currentLimit: 5,
canEdit: false,
minimumLimit: 5,
plan: 'free',
setBy: null,
updatedAt: null,
}
}
const stats = userStatsRecord[0]
return {
currentLimit: Number.parseFloat(stats.currentUsageLimit),
canEdit,
minimumLimit,
plan: subscription?.plan || 'free',
setBy: stats.usageLimitSetBy,
updatedAt: stats.usageLimitUpdatedAt,
}
} catch (error) {
logger.error('Failed to get usage limit info', { userId, error })
throw error
}
}
/**
* 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)
if (existingStats.length > 0) {
return // User already has usage stats, don't override
}
// Create initial usage stats with default $5 limit
await db.insert(userStats).values({
id: crypto.randomUUID(),
userId,
currentUsageLimit: '5', // Default $5 for new users
usageLimitUpdatedAt: new Date(),
billingPeriodStart: new Date(), // Start billing period immediately
})
logger.info('Initialized usage limit for new user', { userId, limit: 5 })
} catch (error) {
logger.error('Failed to initialize usage limit', { userId, error })
throw error
}
}
/**
* Update a user's custom usage limit
*/
export async function updateUserUsageLimit(
userId: string,
newLimit: number,
setBy?: string // For team admin tracking
): Promise<{ success: boolean; error?: string }> {
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
}
}
if (!canEdit) {
if (subscription?.plan === 'team') {
return { success: false, error: 'Only team owners and admins can edit usage limits' }
}
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 = 5
} 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)
}
logger.info('Applying plan-based validation', {
userId,
newLimit,
minimumLimit,
plan: subscription?.plan,
})
// Validate new limit is not below minimum
if (newLimit < minimumLimit) {
return {
success: false,
error: `Usage limit cannot be below plan minimum of $${minimumLimit}`,
}
}
// Update the usage limit
await db
.update(userStats)
.set({
currentUsageLimit: newLimit.toString(),
usageLimitSetBy: setBy || userId,
usageLimitUpdatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
logger.info('Updated user usage limit', {
userId,
newLimit,
setBy: setBy || userId,
planMinimum: minimumLimit,
plan: subscription?.plan,
})
return { success: true }
} catch (error) {
logger.error('Failed to update usage limit', { userId, newLimit, error })
return { success: false, error: 'Failed to update usage limit' }
}
}
/**
* Get usage limit for a user (simple version)
*/
export async function getUserUsageLimit(userId: string): Promise<number> {
try {
const userStatsQuery = await db
.select()
.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 5 // Default free plan limit
}
return Number.parseFloat(userStatsQuery[0].currentUsageLimit)
} catch (error) {
logger.error('Failed to get user usage limit', { userId, error })
return 5 // Fallback to safe default
}
}
/**
* Check usage status with warning thresholds
*/
export async function checkUsageStatus(userId: string): Promise<{
status: 'ok' | 'warning' | 'exceeded'
usageData: UsageData
}> {
try {
const usageData = await getUserUsageData(userId)
let status: 'ok' | 'warning' | 'exceeded' = 'ok'
if (usageData.isExceeded) {
status = 'exceeded'
} else if (usageData.isWarning) {
status = 'warning'
}
return {
status,
usageData,
}
} catch (error) {
logger.error('Failed to check usage status', { userId, error })
throw error
}
}
/**
* 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)
// Get current user stats
const currentUserStats = await db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (currentUserStats.length === 0) {
// Create new user stats with default limit
await db.insert(userStats).values({
id: crypto.randomUUID(),
userId,
currentUsageLimit: defaultLimit.toString(),
usageLimitUpdatedAt: new Date(),
})
logger.info('Created usage stats with synced limit', { userId, limit: defaultLimit })
return
}
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 $5
await db
.update(userStats)
.set({
currentUsageLimit: '5',
usageLimitUpdatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
logger.info('Synced usage limit to free plan', { userId, limit: 5 })
} 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
}
}
/**
* Get usage limit information for team members (for admin dashboard)
*/
export async function getTeamUsageLimits(organizationId: string): Promise<
Array<{
userId: string
userName: string
userEmail: string
currentLimit: number
currentUsage: number
totalCost: number
lastActive: Date | null
limitSetBy: string | null
limitUpdatedAt: Date | null
}>
> {
try {
const teamMembers = await db
.select({
userId: member.userId,
userName: user.name,
userEmail: user.email,
currentLimit: userStats.currentUsageLimit,
currentPeriodCost: userStats.currentPeriodCost,
totalCost: userStats.totalCost,
lastActive: userStats.lastActive,
limitSetBy: userStats.usageLimitSetBy,
limitUpdatedAt: userStats.usageLimitUpdatedAt,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
return teamMembers.map((memberData) => ({
userId: memberData.userId,
userName: memberData.userName,
userEmail: memberData.userEmail,
currentLimit: Number.parseFloat(memberData.currentLimit || '5'),
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 })
return []
}
}
/**
* Calculate billing projection based on current usage
*/
export async function calculateBillingProjection(userId: string): Promise<BillingData> {
try {
const usageData = await getUserUsageData(userId)
if (!usageData.billingPeriodStart || !usageData.billingPeriodEnd) {
return {
currentPeriodCost: usageData.currentUsage,
projectedCost: usageData.currentUsage,
limit: usageData.limit,
billingPeriodStart: null,
billingPeriodEnd: null,
daysRemaining: 0,
}
}
const now = new Date()
const periodStart = new Date(usageData.billingPeriodStart)
const periodEnd = new Date(usageData.billingPeriodEnd)
const totalDays = Math.ceil(
(periodEnd.getTime() - periodStart.getTime()) / (1000 * 60 * 60 * 24)
)
const daysElapsed = Math.ceil((now.getTime() - periodStart.getTime()) / (1000 * 60 * 60 * 24))
const daysRemaining = Math.max(0, totalDays - daysElapsed)
// Project cost based on daily usage rate
const dailyRate = daysElapsed > 0 ? usageData.currentUsage / daysElapsed : 0
const projectedCost = dailyRate * totalDays
return {
currentPeriodCost: usageData.currentUsage,
projectedCost: Math.min(projectedCost, usageData.limit), // Cap at limit
limit: usageData.limit,
billingPeriodStart: usageData.billingPeriodStart,
billingPeriodEnd: usageData.billingPeriodEnd,
daysRemaining,
}
} catch (error) {
logger.error('Failed to calculate billing projection', { userId, error })
throw error
}
}

View File

@@ -0,0 +1,33 @@
/**
* Billing System - Main Entry Point
* Provides clean, organized exports for the billing system
*/
export * from './calculations/usage-monitor'
export * from './core/billing'
export * from './core/billing-periods'
export * from './core/organization-billing'
export * from './core/subscription'
export {
getHighestPrioritySubscription as getActiveSubscription,
getUserSubscriptionState as getSubscriptionState,
isEnterprisePlan as hasEnterprisePlan,
isProPlan as hasProPlan,
isTeamPlan as hasTeamPlan,
} from './core/subscription'
export * from './core/usage'
export {
checkUsageStatus,
getTeamUsageLimits,
getUserUsageData as getUsageData,
getUserUsageLimit as getUsageLimit,
updateUserUsageLimit as updateUsageLimit,
} from './core/usage'
export * from './subscriptions/utils'
export {
calculateDefaultUsageLimit as getDefaultLimit,
canEditUsageLimit as canEditLimit,
getMinimumUsageLimit as getMinimumLimit,
} from './subscriptions/utils'
export * from './types'
export * from './validation/seat-management'

View File

@@ -0,0 +1,91 @@
import Stripe from 'stripe'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console-logger'
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'
)
}
/**
* Secure Stripe client singleton with initialization guard
*/
const createStripeClientSingleton = () => {
let stripeClient: Stripe | null = null
let isInitializing = false
return {
getInstance(): Stripe | null {
// If already initialized, return immediately
if (stripeClient) return stripeClient
// Prevent concurrent initialization attempts
if (isInitializing) {
logger.debug('Stripe client initialization already in progress')
return null
}
if (!hasValidStripeCredentials()) {
logger.warn('Stripe credentials not available - Stripe operations will be disabled')
return null
}
try {
isInitializing = true
stripeClient = new Stripe(env.STRIPE_SECRET_KEY || '', {
apiVersion: '2025-02-24.acacia',
})
logger.info('Stripe client initialized successfully')
return stripeClient
} catch (error) {
logger.error('Failed to initialize Stripe client', { error })
stripeClient = null // Ensure cleanup on failure
return null
} finally {
isInitializing = false
}
},
// For testing purposes only - allows resetting the singleton
reset(): void {
stripeClient = null
isInitializing = false
},
}
}
const stripeClientSingleton = createStripeClientSingleton()
/**
* Get the Stripe client instance
* @returns Stripe client or null if credentials are not available
*/
export function getStripeClient(): Stripe | null {
return stripeClientSingleton.getInstance()
}
/**
* Get the Stripe client instance, throwing an error if not available
* Use this when Stripe operations are required
*/
export function requireStripeClient(): Stripe {
const client = getStripeClient()
if (!client) {
throw new Error(
'Stripe client is not available. Set STRIPE_SECRET_KEY in your environment variables.'
)
}
return client
}

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { calculateUsageLimit, checkEnterprisePlan } from './utils'
import { calculateDefaultUsageLimit, checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
vi.mock('../env', () => ({
vi.mock('@/lib/env', () => ({
env: {
FREE_TIER_COST_LIMIT: 5,
PRO_TIER_COST_LIMIT: 20,
@@ -25,25 +25,25 @@ describe('Subscription Utilities', () => {
})
})
describe('calculateUsageLimit', () => {
describe('calculateDefaultUsageLimit', () => {
it.concurrent('returns free-tier limit when subscription is null', () => {
expect(calculateUsageLimit(null)).toBe(5)
expect(calculateDefaultUsageLimit(null)).toBe(5)
})
it.concurrent('returns free-tier limit when subscription is undefined', () => {
expect(calculateUsageLimit(undefined)).toBe(5)
expect(calculateDefaultUsageLimit(undefined)).toBe(5)
})
it.concurrent('returns free-tier limit when subscription is not active', () => {
expect(calculateUsageLimit({ plan: 'pro', status: 'canceled', seats: 1 })).toBe(5)
expect(calculateDefaultUsageLimit({ plan: 'pro', status: 'canceled', seats: 1 })).toBe(5)
})
it.concurrent('returns pro limit for active pro plan', () => {
expect(calculateUsageLimit({ plan: 'pro', status: 'active', seats: 1 })).toBe(20)
expect(calculateDefaultUsageLimit({ plan: 'pro', status: 'active', seats: 1 })).toBe(20)
})
it.concurrent('returns team limit multiplied by seats', () => {
expect(calculateUsageLimit({ plan: 'team', status: 'active', seats: 3 })).toBe(3 * 40)
expect(calculateDefaultUsageLimit({ plan: 'team', status: 'active', seats: 3 })).toBe(3 * 40)
})
it.concurrent('returns enterprise limit using perSeatAllowance metadata', () => {
@@ -53,7 +53,7 @@ describe('Subscription Utilities', () => {
seats: 10,
metadata: { perSeatAllowance: '150' },
}
expect(calculateUsageLimit(sub)).toBe(10 * 150)
expect(calculateDefaultUsageLimit(sub)).toBe(10 * 150)
})
it.concurrent('returns enterprise limit using totalAllowance metadata', () => {
@@ -63,12 +63,12 @@ describe('Subscription Utilities', () => {
seats: 8,
metadata: { totalAllowance: '5000' },
}
expect(calculateUsageLimit(sub)).toBe(5000)
expect(calculateDefaultUsageLimit(sub)).toBe(5000)
})
it.concurrent('falls back to default enterprise tier when metadata missing', () => {
const sub = { plan: 'enterprise', status: 'active', seats: 2, metadata: {} }
expect(calculateUsageLimit(sub)).toBe(2 * 200)
expect(calculateDefaultUsageLimit(sub)).toBe(2 * 200)
})
})
})

View File

@@ -0,0 +1,77 @@
import { env } from '@/lib/env'
export function checkEnterprisePlan(subscription: any): boolean {
return subscription?.plan === 'enterprise' && subscription?.status === 'active'
}
export function checkProPlan(subscription: any): boolean {
return subscription?.plan === 'pro' && subscription?.status === 'active'
}
export function checkTeamPlan(subscription: any): boolean {
return subscription?.plan === 'team' && subscription?.status === 'active'
}
/**
* Calculate default usage limit for a subscription based on its type and metadata
* This is now used as the minimum limit for paid plans
* @param subscription The subscription object
* @returns The calculated default usage limit in dollars
*/
export function calculateDefaultUsageLimit(subscription: any): number {
if (!subscription || subscription.status !== 'active') {
return env.FREE_TIER_COST_LIMIT || 0
}
const seats = subscription.seats || 1
if (subscription.plan === 'pro') {
return env.PRO_TIER_COST_LIMIT || 0
}
if (subscription.plan === 'team') {
return seats * (env.TEAM_TIER_COST_LIMIT || 0)
}
if (subscription.plan === 'enterprise') {
const metadata = subscription.metadata || {}
if (metadata.perSeatAllowance) {
return seats * Number.parseFloat(metadata.perSeatAllowance)
}
if (metadata.totalAllowance) {
return Number.parseFloat(metadata.totalAllowance)
}
return seats * (env.ENTERPRISE_TIER_COST_LIMIT || 0)
}
return env.FREE_TIER_COST_LIMIT || 0
}
/**
* Check if a user can edit their usage limits based on their subscription
* Free plan users cannot edit limits, paid plan users can
* @param subscription The subscription object
* @returns Whether the user can edit their usage limits
*/
export function canEditUsageLimit(subscription: any): boolean {
if (!subscription || subscription.status !== 'active') {
return false // Free plan users cannot edit limits
}
return (
subscription.plan === 'pro' ||
subscription.plan === 'team' ||
subscription.plan === 'enterprise'
)
}
/**
* Get the minimum allowed usage limit for a subscription
* This prevents users from setting limits below their plan's base amount
* @param subscription The subscription object
* @returns The minimum allowed usage limit in dollars
*/
export function getMinimumUsageLimit(subscription: any): number {
return calculateDefaultUsageLimit(subscription)
}

View File

@@ -0,0 +1,219 @@
/**
* Billing System Types
* Centralized type definitions for the billing system
*/
export interface SubscriptionFeatures {
sharingEnabled: boolean
multiplayerEnabled: boolean
workspaceCollaborationEnabled: boolean
}
export interface UsageData {
currentUsage: number
limit: number
percentUsed: number
isWarning: boolean
isExceeded: boolean
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
lastPeriodCost: number
}
export interface UsageLimitInfo {
currentLimit: number
canEdit: boolean
minimumLimit: number
plan: string
setBy: string | null
updatedAt: Date | null
}
export interface BillingData {
currentPeriodCost: number
projectedCost: number
limit: number
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
daysRemaining: number
}
export interface UserSubscriptionState {
isPro: boolean
isTeam: boolean
isEnterprise: boolean
isFree: boolean
highestPrioritySubscription: any | null
features: SubscriptionFeatures
hasExceededLimit: boolean
planName: string
}
export interface SubscriptionPlan {
name: string
priceId: string
limits: {
cost: number
sharingEnabled: number
multiplayerEnabled: number
workspaceCollaborationEnabled: number
}
}
export interface BillingEntity {
id: string
type: 'user' | 'organization'
referenceId: string
metadata?: { stripeCustomerId?: string; [key: string]: any } | null
createdAt: Date
updatedAt: Date
}
export interface BillingConfig {
id: string
entityType: 'user' | 'organization'
entityId: string
usageLimit: number
limitSetBy?: string
limitUpdatedAt?: Date
billingPeriodType: 'monthly' | 'annual'
autoResetEnabled: boolean
createdAt: Date
updatedAt: Date
}
export interface UsagePeriod {
id: string
entityType: 'user' | 'organization'
entityId: string
periodStart: Date
periodEnd: Date
totalCost: number
finalCost?: number
isCurrent: boolean
status: 'active' | 'finalized' | 'billed'
createdAt: Date
finalizedAt?: Date
}
export interface BillingStatus {
status: 'ok' | 'warning' | 'exceeded'
usageData: UsageData
}
export interface TeamUsageLimit {
userId: string
userName: string
userEmail: string
currentLimit: number
currentUsage: number
totalCost: number
lastActive: Date | null
limitSetBy: string | null
limitUpdatedAt: Date | null
}
export interface BillingSummary {
userId: string
email: string
name: string
currentPeriodCost: number
currentUsageLimit: number
currentUsagePercentage: number
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
plan: string
subscriptionStatus: string | null
seats: number | null
billingStatus: 'ok' | 'warning' | 'exceeded'
}
// API Response Types
export interface SubscriptionAPIResponse {
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
plan: string
status: string | null
seats: number | null
metadata: any | null
features: SubscriptionFeatures
usage: UsageData
}
export interface UsageLimitAPIResponse {
currentLimit: number
canEdit: boolean
minimumLimit: number
plan: string
setBy?: string
updatedAt?: Date
}
// Utility Types
export type PlanType = 'free' | 'pro' | 'team' | 'enterprise'
export type SubscriptionStatus =
| 'active'
| 'canceled'
| 'past_due'
| 'unpaid'
| 'trialing'
| 'incomplete'
| 'incomplete_expired'
export type BillingEntityType = 'user' | 'organization'
export type BillingPeriodType = 'monthly' | 'annual'
export type UsagePeriodStatus = 'active' | 'finalized' | 'billed'
export type BillingStatusType = 'ok' | 'warning' | 'exceeded'
// Error Types
export interface BillingError {
code: string
message: string
details?: any
}
export interface UpdateUsageLimitResult {
success: boolean
error?: string
}
// Hook Types for React
export interface UseSubscriptionStateReturn {
subscription: {
isPaid: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
isFree: boolean
plan: string
status?: string
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
getBillingStatus: () => BillingStatusType
getRemainingBudget: () => number
getDaysRemainingInPeriod: () => number | null
}
export interface UseUsageLimitReturn {
currentLimit: number
canEdit: boolean
minimumLimit: number
plan: string
setBy?: string
updatedAt?: Date
updateLimit: (newLimit: number) => Promise<{ success: boolean }>
isLoading: boolean
error: Error | null
refetch: () => Promise<any>
}

View File

@@ -0,0 +1,451 @@
import { and, count, eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { invitation, member, organization, subscription, user, userStats } from '@/db/schema'
import { getHighestPrioritySubscription } from '../core/subscription'
const logger = createLogger('SeatManagement')
interface SeatValidationResult {
canInvite: boolean
reason?: string
currentSeats: number
maxSeats: number
availableSeats: number
}
interface OrganizationSeatInfo {
organizationId: string
organizationName: string
currentSeats: number
maxSeats: number
availableSeats: number
subscriptionPlan: string
canAddSeats: boolean
}
/**
* Validate if an organization can invite new members based on seat limits
*/
export async function validateSeatAvailability(
organizationId: string,
additionalSeats = 1
): Promise<SeatValidationResult> {
try {
// Get organization subscription
const subscription = await getHighestPrioritySubscription(organizationId)
if (!subscription) {
return {
canInvite: false,
reason: 'No active subscription found',
currentSeats: 0,
maxSeats: 0,
availableSeats: 0,
}
}
// Free and Pro plans don't support organizations
if (['free', 'pro'].includes(subscription.plan)) {
return {
canInvite: false,
reason: 'Organization features require Team or Enterprise plan',
currentSeats: 0,
maxSeats: 0,
availableSeats: 0,
}
}
// Get current member count
const memberCount = await db
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId))
const currentSeats = memberCount[0]?.count || 0
// Determine seat limits based on subscription
let maxSeats = subscription.seats || 1
// For enterprise plans, check metadata for custom seat allowances
if (subscription.plan === 'enterprise' && subscription.metadata) {
try {
const metadata = JSON.parse(subscription.metadata)
if (metadata.maxSeats) {
maxSeats = metadata.maxSeats
}
} catch (error) {
logger.warn('Failed to parse enterprise subscription metadata', {
organizationId,
metadata: subscription.metadata,
error,
})
}
}
const availableSeats = Math.max(0, maxSeats - currentSeats)
const canInvite = availableSeats >= additionalSeats
const result: SeatValidationResult = {
canInvite,
currentSeats,
maxSeats,
availableSeats,
}
if (!canInvite) {
if (additionalSeats === 1) {
result.reason = `No available seats. Currently using ${currentSeats} of ${maxSeats} seats.`
} else {
result.reason = `Not enough available seats. Need ${additionalSeats} seats, but only ${availableSeats} available.`
}
}
logger.debug('Seat validation result', {
organizationId,
additionalSeats,
result,
})
return result
} catch (error) {
logger.error('Failed to validate seat availability', { organizationId, additionalSeats, error })
return {
canInvite: false,
reason: 'Failed to check seat availability',
currentSeats: 0,
maxSeats: 0,
availableSeats: 0,
}
}
}
/**
* Get comprehensive seat information for an organization
*/
export async function getOrganizationSeatInfo(
organizationId: string
): Promise<OrganizationSeatInfo | null> {
try {
// Get organization details
const organizationData = await db
.select({
id: organization.id,
name: organization.name,
})
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (organizationData.length === 0) {
return null
}
// Get subscription
const subscription = await getHighestPrioritySubscription(organizationId)
if (!subscription) {
return null
}
// Get current member count
const memberCount = await db
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId))
const currentSeats = memberCount[0]?.count || 0
// Determine seat limits
let maxSeats = subscription.seats || 1
let canAddSeats = true
if (subscription.plan === 'enterprise' && subscription.metadata) {
try {
const metadata = JSON.parse(subscription.metadata)
if (metadata.maxSeats) {
maxSeats = metadata.maxSeats
}
// Enterprise plans might have fixed seat counts
canAddSeats = !metadata.fixedSeats
} catch (error) {
logger.warn('Failed to parse enterprise subscription metadata', { organizationId, error })
}
}
const availableSeats = Math.max(0, maxSeats - currentSeats)
return {
organizationId,
organizationName: organizationData[0].name,
currentSeats,
maxSeats,
availableSeats,
subscriptionPlan: subscription.plan,
canAddSeats,
}
} catch (error) {
logger.error('Failed to get organization seat info', { organizationId, error })
return null
}
}
/**
* Validate and reserve seats for bulk invitations
*/
export async function validateBulkInvitations(
organizationId: string,
emailList: string[]
): Promise<{
canInviteAll: boolean
validEmails: string[]
duplicateEmails: string[]
existingMembers: string[]
seatsNeeded: number
seatsAvailable: number
validationResult: SeatValidationResult
}> {
try {
// Remove duplicates and validate email format
const uniqueEmails = [...new Set(emailList)]
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const validEmails = uniqueEmails.filter((email) => emailRegex.test(email))
const duplicateEmails = emailList.filter((email, index) => emailList.indexOf(email) !== index)
// Check for existing members
const existingMembers = await db
.select({ userEmail: user.email })
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(eq(member.organizationId, organizationId))
const existingEmails = existingMembers.map((m) => m.userEmail)
const newEmails = validEmails.filter((email) => !existingEmails.includes(email))
// Check for pending invitations
const pendingInvitations = await db
.select({ email: invitation.email })
.from(invitation)
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
const pendingEmails = pendingInvitations.map((i) => i.email)
const finalEmailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email))
// Validate seat availability
const seatsNeeded = finalEmailsToInvite.length
const validationResult = await validateSeatAvailability(organizationId, seatsNeeded)
return {
canInviteAll: validationResult.canInvite && finalEmailsToInvite.length > 0,
validEmails: finalEmailsToInvite,
duplicateEmails,
existingMembers: validEmails.filter((email) => existingEmails.includes(email)),
seatsNeeded,
seatsAvailable: validationResult.availableSeats,
validationResult,
}
} catch (error) {
logger.error('Failed to validate bulk invitations', {
organizationId,
emailCount: emailList.length,
error,
})
const validationResult: SeatValidationResult = {
canInvite: false,
reason: 'Validation failed',
currentSeats: 0,
maxSeats: 0,
availableSeats: 0,
}
return {
canInviteAll: false,
validEmails: [],
duplicateEmails: [],
existingMembers: [],
seatsNeeded: 0,
seatsAvailable: 0,
validationResult,
}
}
}
/**
* Update organization seat count in subscription
*/
export async function updateOrganizationSeats(
organizationId: string,
newSeatCount: number,
updatedBy: string
): Promise<{ success: boolean; error?: string }> {
try {
// Get current subscription
const subscriptionRecord = await getHighestPrioritySubscription(organizationId)
if (!subscriptionRecord) {
return { success: false, error: 'No active subscription found' }
}
// Validate minimum seat requirements
const memberCount = await db
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId))
const currentMembers = memberCount[0]?.count || 0
if (newSeatCount < currentMembers) {
return {
success: false,
error: `Cannot reduce seats below current member count (${currentMembers})`,
}
}
// Update subscription seat count
await db
.update(subscription)
.set({
seats: newSeatCount,
})
.where(eq(subscription.id, subscriptionRecord.id))
logger.info('Organization seat count updated', {
organizationId,
oldSeatCount: subscriptionRecord.seats,
newSeatCount,
updatedBy,
})
return { success: true }
} catch (error) {
logger.error('Failed to update organization seats', {
organizationId,
newSeatCount,
updatedBy,
error,
})
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Check if a user can be removed from an organization
*/
export async function validateMemberRemoval(
organizationId: string,
userIdToRemove: string,
removedBy: string
): Promise<{ canRemove: boolean; reason?: string }> {
try {
// Get member details
const memberRecord = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, userIdToRemove)))
.limit(1)
if (memberRecord.length === 0) {
return { canRemove: false, reason: 'Member not found in organization' }
}
// Check if trying to remove the organization owner
if (memberRecord[0].role === 'owner') {
return { canRemove: false, reason: 'Cannot remove organization owner' }
}
// Check if the person removing has sufficient permissions
const removerMemberRecord = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, removedBy)))
.limit(1)
if (removerMemberRecord.length === 0) {
return { canRemove: false, reason: 'You are not a member of this organization' }
}
const removerRole = removerMemberRecord[0].role
const targetRole = memberRecord[0].role
// Permission hierarchy: owner > admin > member
if (removerRole === 'owner') {
// Owners can remove anyone except themselves
return userIdToRemove === removedBy
? { canRemove: false, reason: 'Cannot remove yourself as owner' }
: { canRemove: true }
}
if (removerRole === 'admin') {
// Admins can remove members but not other admins or owners
return targetRole === 'member'
? { canRemove: true }
: { canRemove: false, reason: 'Insufficient permissions to remove this member' }
}
// Members cannot remove other members
return { canRemove: false, reason: 'Insufficient permissions' }
} catch (error) {
logger.error('Failed to validate member removal', {
organizationId,
userIdToRemove,
removedBy,
error,
})
return { canRemove: false, reason: 'Validation failed' }
}
}
/**
* Get seat usage analytics for an organization
*/
export async function getOrganizationSeatAnalytics(organizationId: string) {
try {
const seatInfo = await getOrganizationSeatInfo(organizationId)
if (!seatInfo) {
return null
}
// Get member activity data
const memberActivity = await db
.select({
userId: member.userId,
userName: user.name,
userEmail: user.email,
role: member.role,
joinedAt: member.createdAt,
lastActive: userStats.lastActive,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
// Calculate utilization metrics
const utilizationRate =
seatInfo.maxSeats > 0 ? (seatInfo.currentSeats / seatInfo.maxSeats) * 100 : 0
const recentlyActive = memberActivity.filter((memberData) => {
if (!memberData.lastActive) return false
const daysSinceActive = (Date.now() - memberData.lastActive.getTime()) / (1000 * 60 * 60 * 24)
return daysSinceActive <= 30 // Active in last 30 days
}).length
return {
...seatInfo,
utilizationRate: Math.round(utilizationRate * 100) / 100,
activeMembers: recentlyActive,
inactiveMembers: seatInfo.currentSeats - recentlyActive,
memberActivity,
}
} catch (error) {
logger.error('Failed to get organization seat analytics', { organizationId, error })
return null
}
}

View File

@@ -0,0 +1,152 @@
import type Stripe from 'stripe'
import { createLogger } from '@/lib/logs/console-logger'
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
// Check if this is an overage billing invoice
if (invoice.metadata?.type !== 'overage_billing') {
logger.info('Ignoring non-overage billing invoice', { invoiceId: invoice.id })
return
}
const customerId = invoice.customer as string
const chargedAmount = invoice.amount_paid / 100 // Convert from cents to dollars
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,
})
// Additional payment success logic can be added here
// For example: update internal billing status, trigger analytics events, etc.
} 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
// Check if this is an overage billing invoice
if (invoice.metadata?.type !== 'overage_billing') {
logger.info('Ignoring non-overage billing invoice finalization', { invoiceId: invoice.id })
return
}
const customerId = invoice.customer as string
const invoiceAmount = invoice.amount_due / 100 // Convert from cents to dollars
const billingPeriod = invoice.metadata?.billingPeriod || 'unknown'
logger.info('Overage billing invoice finalized', {
invoiceId: invoice.id,
customerId,
invoiceAmount,
billingPeriod,
customerEmail: invoice.customer_email,
hostedInvoiceUrl: invoice.hosted_invoice_url,
})
// Additional invoice finalization logic can be added here
// For example: update internal records, trigger notifications, etc.
} 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

@@ -1,7 +1,8 @@
import { eq } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { getCostMultiplier } from '@/lib/environment'
import { db } from '@/db'
import { workflowExecutionBlocks, workflowExecutionLogs } from '@/db/schema'
import { userStats, workflow, workflowExecutionBlocks, workflowExecutionLogs } from '@/db/schema'
import { createLogger } from './console-logger'
import { snapshotService } from './snapshot-service'
import type {
@@ -18,6 +19,17 @@ import type {
WorkflowState,
} from './types'
export interface ToolCall {
name: string
duration: number // in milliseconds
startTime: string // ISO timestamp
endTime: string // ISO timestamp
status: 'success' | 'error'
input?: Record<string, any>
output?: Record<string, any>
error?: string
}
const logger = createLogger('EnhancedExecutionLogger')
export class EnhancedExecutionLogger implements IExecutionLoggerService {
@@ -119,6 +131,7 @@ export class EnhancedExecutionLogger implements IExecutionLoggerService {
}
cost?: CostBreakdown
metadata?: BlockExecutionLog['metadata']
toolCalls?: ToolCall[]
}): Promise<BlockExecutionLog> {
const {
executionId,
@@ -133,6 +146,7 @@ export class EnhancedExecutionLogger implements IExecutionLoggerService {
error,
cost,
metadata,
toolCalls,
} = params
logger.debug(`Logging block execution ${blockId} for execution ${executionId}`)
@@ -163,7 +177,10 @@ export class EnhancedExecutionLogger implements IExecutionLoggerService {
tokensCompletion: cost?.tokens?.completion || null,
tokensTotal: cost?.tokens?.total || null,
modelUsed: cost?.model || null,
metadata: metadata || {},
metadata: {
...(metadata || {}),
...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}),
},
})
.returning()
@@ -266,6 +283,13 @@ export class EnhancedExecutionLogger implements IExecutionLoggerService {
throw new Error(`Workflow log not found for execution ${executionId}`)
}
// Update user stats with cost information (same logic as original execution logger)
await this.updateUserStats(
updatedLog.workflowId,
costSummary,
updatedLog.trigger as ExecutionTrigger['type']
)
logger.debug(`Completed workflow execution ${executionId}`)
return {
@@ -370,6 +394,149 @@ export class EnhancedExecutionLogger implements IExecutionLoggerService {
}
}
/**
* Updates user stats with cost and token information
* Maintains same logic as original execution logger for billing consistency
*/
private async updateUserStats(
workflowId: string,
costSummary: {
totalCost: number
totalInputCost: number
totalOutputCost: number
totalTokens: number
totalPromptTokens: number
totalCompletionTokens: number
},
trigger: ExecutionTrigger['type']
): Promise<void> {
if (costSummary.totalCost <= 0) {
logger.debug('No cost to update in user stats')
return
}
try {
// Get the workflow record to get the userId
const [workflowRecord] = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
logger.error(`Workflow ${workflowId} not found for user stats update`)
return
}
const userId = workflowRecord.userId
const costMultiplier = getCostMultiplier()
const costToStore = costSummary.totalCost * costMultiplier
// 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
const updateFields: any = {
totalTokensUsed: sql`total_tokens_used + ${costSummary.totalTokens}`,
totalCost: sql`total_cost + ${costToStore}`,
currentPeriodCost: sql`current_period_cost + ${costToStore}`, // Track current billing period usage
lastActive: new Date(),
}
// Add trigger-specific increment
switch (trigger) {
case 'manual':
updateFields.totalManualExecutions = sql`total_manual_executions + 1`
break
case 'api':
updateFields.totalApiCalls = sql`total_api_calls + 1`
break
case 'webhook':
updateFields.totalWebhookTriggers = sql`total_webhook_triggers + 1`
break
case 'schedule':
updateFields.totalScheduledExecutions = sql`total_scheduled_executions + 1`
break
case 'chat':
updateFields.totalChatExecutions = sql`total_chat_executions + 1`
break
}
await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId))
logger.debug('Updated existing user stats record with cost data', {
userId,
trigger,
addedCost: costToStore,
addedTokens: costSummary.totalTokens,
})
}
} catch (error) {
logger.error('Error updating user stats with cost information', {
workflowId,
error,
costSummary,
})
// Don't throw - we want execution to continue even if user stats update fails
}
}
/**
* 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':

View File

@@ -632,6 +632,7 @@ export async function persistExecutionLogs(
totalChatExecutions: 0,
totalTokensUsed: totalTokens,
totalCost: costToStore.toString(),
currentPeriodCost: costToStore.toString(), // Initialize current period usage
lastActive: new Date(),
})
} else {
@@ -640,6 +641,7 @@ export async function persistExecutionLogs(
.set({
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
totalCost: sql`total_cost + ${costToStore}`,
currentPeriodCost: sql`current_period_cost + ${costToStore}`, // Track current billing period usage
lastActive: new Date(),
})
.where(eq(userStats.userId, userId))

View File

@@ -1,6 +1,6 @@
import { and, eq } from 'drizzle-orm'
import { db } from '@/db'
import { permissions, type permissionTypeEnum, user } from '@/db/schema'
import { member, permissions, type permissionTypeEnum, user, workspace } from '@/db/schema'
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
@@ -96,3 +96,227 @@ export async function getUsersWithPermissions(workspaceId: string) {
permissionType: row.permissionType,
}))
}
/**
* Check if a user is an admin or owner of any organization that has access to a workspace
*
* @param userId - The ID of the user to check
* @param workspaceId - The ID of the workspace
* @returns Promise<boolean> - True if the user is an organization admin with access to the workspace
*/
export async function isOrganizationAdminForWorkspace(
userId: string,
workspaceId: string
): Promise<boolean> {
try {
// Get the workspace owner
const workspaceRecord = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (workspaceRecord.length === 0) {
return false
}
const workspaceOwnerId = workspaceRecord[0].ownerId
// Check if the user is an admin/owner of any organization that the workspace owner belongs to
const orgMemberships = await db
.select({
organizationId: member.organizationId,
role: member.role,
})
.from(member)
.where(
and(
eq(member.userId, userId),
// Only admin and owner roles can manage workspace permissions
eq(member.role, 'admin') // We'll also check for 'owner' separately
)
)
// Also check for owner role
const ownerMemberships = await db
.select({
organizationId: member.organizationId,
role: member.role,
})
.from(member)
.where(and(eq(member.userId, userId), eq(member.role, 'owner')))
const allOrgMemberships = [...orgMemberships, ...ownerMemberships]
if (allOrgMemberships.length === 0) {
return false
}
// Check if the workspace owner is a member of any of these organizations
for (const membership of allOrgMemberships) {
const workspaceOwnerInOrg = await db
.select()
.from(member)
.where(
and(
eq(member.userId, workspaceOwnerId),
eq(member.organizationId, membership.organizationId)
)
)
.limit(1)
if (workspaceOwnerInOrg.length > 0) {
return true
}
}
return false
} catch (error) {
console.error('Error checking organization admin status for workspace:', error)
return false
}
}
/**
* Check if a user has admin permissions (either direct workspace admin or organization admin)
*
* @param userId - The ID of the user to check permissions for
* @param workspaceId - The ID of the workspace to check admin permission for
* @returns Promise<boolean> - True if the user has admin permission for the workspace, false otherwise
*/
export async function hasWorkspaceAdminAccess(
userId: string,
workspaceId: string
): Promise<boolean> {
// Check direct workspace admin permission
const directAdmin = await hasAdminPermission(userId, workspaceId)
if (directAdmin) {
return true
}
// Check organization admin permission
const orgAdmin = await isOrganizationAdminForWorkspace(userId, workspaceId)
return orgAdmin
}
/**
* Get all workspaces that a user can manage (either as direct admin or organization admin)
*
* @param userId - The ID of the user
* @returns Promise<Array<{id: string, name: string, ownerId: string}>> - Array of workspaces the user can manage
*/
export async function getManageableWorkspaces(userId: string): Promise<
Array<{
id: string
name: string
ownerId: string
accessType: 'direct' | 'organization'
}>
> {
const manageableWorkspaces: Array<{
id: string
name: string
ownerId: string
accessType: 'direct' | 'organization'
}> = []
// Get workspaces where user has direct admin permissions
const directWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
})
.from(workspace)
.innerJoin(permissions, eq(permissions.entityId, workspace.id))
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.permissionType, 'admin')
)
)
directWorkspaces.forEach((ws) => {
manageableWorkspaces.push({
...ws,
accessType: 'direct',
})
})
// Get workspaces where user has organization admin access
// First, get organizations where the user is admin/owner
const adminOrgs = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(
and(
eq(member.userId, userId)
// Check for both admin and owner roles
)
)
// Get all organization workspaces for these orgs
for (const org of adminOrgs) {
// Get all members of this organization
const orgMembers = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, org.organizationId))
// Get workspaces owned by org members
const orgWorkspaces = await db
.select({
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
})
.from(workspace)
.where(
// Find workspaces owned by any org member
eq(workspace.ownerId, orgMembers.length > 0 ? orgMembers[0].userId : 'none')
)
// Add these workspaces if not already included
orgWorkspaces.forEach((ws) => {
if (!manageableWorkspaces.find((existing) => existing.id === ws.id)) {
manageableWorkspaces.push({
...ws,
accessType: 'organization',
})
}
})
}
return manageableWorkspaces
}
/**
* Check if a user is an owner or admin of a specific organization
*
* @param userId - The ID of the user to check
* @param organizationId - The ID of the organization
* @returns Promise<boolean> - True if the user is an owner or admin of the organization
*/
export async function isOrganizationOwnerOrAdmin(
userId: string,
organizationId: string
): Promise<boolean> {
try {
const memberRecord = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
.limit(1)
if (memberRecord.length === 0) {
return false // User is not a member of the organization
}
const userRole = memberRecord[0].role
return ['owner', 'admin'].includes(userRole)
} catch (error) {
console.error('Error checking organization ownership/admin status:', error)
return false
}
}

View File

@@ -1,343 +0,0 @@
import { eq } from 'drizzle-orm'
import { isProd } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { client } from './auth-client'
import { env } from './env'
const logger = createLogger('Subscription')
/**
* Check if the user is on the Pro plan
*/
export async function isProPlan(userId: string): Promise<boolean> {
try {
// In development, enable Pro features for easier testing
if (!isProd) {
return true
}
// First check organizations the user belongs to (prioritize org subscriptions)
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for active Pro or Team subscriptions
for (const membership of memberships) {
const orgSubscriptions = await db
.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, membership.organizationId))
const orgHasProPlan = orgSubscriptions.some(
(sub) => sub.status === 'active' && (sub.plan === 'pro' || sub.plan === 'team')
)
if (orgHasProPlan) {
logger.info('User has pro plan via organization', {
userId,
orgId: membership.organizationId,
})
return true
}
}
// If no org subscriptions, check direct subscriptions
const directSubscriptions = await db
.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, userId))
// Find active pro subscription (either Pro or Team plan)
const hasDirectProPlan = directSubscriptions.some(
(sub) => sub.status === 'active' && (sub.plan === 'pro' || sub.plan === 'team')
)
if (hasDirectProPlan) {
logger.info('User has direct pro plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking pro plan status', { error, userId })
return false
}
}
/**
* Check if the user is on the Team plan
*/
export async function isTeamPlan(userId: string): Promise<boolean> {
try {
// In development, enable Team features for easier testing
if (!isProd) {
return true
}
// First check organizations the user belongs to (prioritize org subscriptions)
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for active Team subscriptions
for (const membership of memberships) {
const orgSubscriptions = await db
.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, membership.organizationId))
const orgHasTeamPlan = orgSubscriptions.some(
(sub) => sub.status === 'active' && sub.plan === 'team'
)
if (orgHasTeamPlan) {
return true
}
}
// If no org subscriptions found, check direct subscriptions
const directSubscriptions = await db
.select()
.from(schema.subscription)
.where(eq(schema.subscription.referenceId, userId))
// Find active team subscription
const hasDirectTeamPlan = directSubscriptions.some(
(sub) => sub.status === 'active' && sub.plan === 'team'
)
if (hasDirectTeamPlan) {
logger.info('User has direct team plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking team plan status', { error, userId })
return false
}
}
/**
* Check if a user has exceeded their cost limit based on their subscription plan
*/
export async function hasExceededCostLimit(userId: string): Promise<boolean> {
try {
// In development, users never exceed their limit
if (!isProd) {
return false
}
// Get user's direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
// Find active direct subscription
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
// Get organizations the user belongs to
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
let highestCostLimit = 0
// Check cost limit from direct subscription
if (activeDirectSubscription && typeof activeDirectSubscription.limits?.cost === 'number') {
highestCostLimit = activeDirectSubscription.limits.cost
}
// Check cost limits from organization subscriptions
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (
activeOrgSubscription &&
typeof activeOrgSubscription.limits?.cost === 'number' &&
activeOrgSubscription.limits.cost > highestCostLimit
) {
highestCostLimit = activeOrgSubscription.limits.cost
}
}
// If no subscription found, use default free tier limit
if (highestCostLimit === 0) {
highestCostLimit = env.FREE_TIER_COST_LIMIT ?? 5
}
logger.info('User cost limit from subscription', { userId, costLimit: highestCostLimit })
// Get user's actual usage from the database
const statsRecords = await db
.select()
.from(schema.userStats)
.where(eq(schema.userStats.userId, userId))
if (statsRecords.length === 0) {
// No usage yet, so they haven't exceeded the limit
return false
}
// Get the current cost and compare with the limit
const currentCost = Number.parseFloat(statsRecords[0].totalCost.toString())
return currentCost >= highestCostLimit
} catch (error) {
logger.error('Error checking cost limit', { error, userId })
return false // Be conservative in case of error
}
}
/**
* Check if a user is allowed to share workflows based on their subscription plan
*/
export async function isSharingEnabled(userId: string): Promise<boolean> {
try {
// In development, always allow sharing
if (!isProd) {
return true
}
// Check direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
// If user has direct pro/team subscription with sharing enabled
if (activeDirectSubscription?.limits?.sharingEnabled) {
return true
}
// Check organizations the user belongs to
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for a subscription with sharing enabled
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription?.limits?.sharingEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking sharing permission', { error, userId })
return false // Be conservative in case of error
}
}
/**
* Check if multiplayer collaboration is enabled for the user
*/
export async function isMultiplayerEnabled(userId: string): Promise<boolean> {
try {
// In development, always enable multiplayer
if (!isProd) {
return true
}
// Check direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
// If user has direct team subscription with multiplayer enabled
if (activeDirectSubscription?.limits?.multiplayerEnabled) {
return true
}
// Check organizations the user belongs to
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for a subscription with multiplayer enabled
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription?.limits?.multiplayerEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking multiplayer permission', { error, userId })
return false // Be conservative in case of error
}
}
/**
* Check if workspace collaboration is enabled for the user
*/
export async function isWorkspaceCollaborationEnabled(userId: string): Promise<boolean> {
try {
// In development, always enable workspace collaboration
if (!isProd) {
return true
}
// Check direct subscription
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
// If user has direct team subscription with workspace collaboration enabled
if (activeDirectSubscription?.limits?.workspaceCollaborationEnabled) {
return true
}
// Check organizations the user belongs to
const memberships = await db
.select()
.from(schema.member)
.where(eq(schema.member.userId, userId))
// Check each organization for a subscription with workspace collaboration enabled
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription?.limits?.workspaceCollaborationEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking workspace collaboration permission', { error, userId })
return false // Be conservative in case of error
}
}

View File

@@ -1,344 +0,0 @@
import { and, eq, inArray } from 'drizzle-orm'
import { isProd } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { member, subscription, userStats } from '@/db/schema'
import { client } from '../auth-client'
import { env } from '../env'
import { calculateUsageLimit, checkEnterprisePlan, checkProPlan, checkTeamPlan } from './utils'
const logger = createLogger('Subscription')
export async function isProPlan(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const directSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, userId))
const hasDirectProPlan = directSubscriptions.some(checkProPlan)
if (hasDirectProPlan) {
logger.info('User has direct pro plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking pro plan status', { error, userId })
return false
}
}
export async function isTeamPlan(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const memberships = await db.select().from(member).where(eq(member.userId, userId))
for (const membership of memberships) {
const orgSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, membership.organizationId))
const orgHasTeamPlan = orgSubscriptions.some(
(sub) => sub.status === 'active' && sub.plan === 'team'
)
if (orgHasTeamPlan) {
return true
}
}
const directSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, userId))
const hasDirectTeamPlan = directSubscriptions.some(checkTeamPlan)
if (hasDirectTeamPlan) {
logger.info('User has direct team plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking team plan status', { error, userId })
return false
}
}
export async function isEnterprisePlan(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const memberships = await db.select().from(member).where(eq(member.userId, userId))
for (const membership of memberships) {
const orgSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, membership.organizationId))
const orgHasEnterprisePlan = orgSubscriptions.some((sub) => checkEnterprisePlan(sub))
if (orgHasEnterprisePlan) {
logger.info('User has enterprise plan via organization', {
userId,
orgId: membership.organizationId,
})
return true
}
}
const directSubscriptions = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, userId))
const hasDirectEnterprisePlan = directSubscriptions.some(checkEnterprisePlan)
if (hasDirectEnterprisePlan) {
logger.info('User has direct enterprise plan', { userId })
return true
}
return false
} catch (error) {
logger.error('Error checking enterprise plan status', { error, userId })
return false
}
}
export async function hasExceededCostLimit(userId: string): Promise<boolean> {
try {
if (!isProd) {
return false
}
let activeSubscription = null
const userSubscriptions = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
if (userSubscriptions.length > 0) {
const enterpriseSub = userSubscriptions.find(checkEnterprisePlan)
const teamSub = userSubscriptions.find(checkTeamPlan)
const proSub = userSubscriptions.find(checkProPlan)
activeSubscription = enterpriseSub || teamSub || proSub || null
}
if (!activeSubscription) {
const memberships = await db.select().from(member).where(eq(member.userId, userId))
for (const membership of memberships) {
const orgId = membership.organizationId
const orgSubscriptions = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, orgId), eq(subscription.status, 'active')))
if (orgSubscriptions.length > 0) {
const orgEnterpriseSub = orgSubscriptions.find(checkEnterprisePlan)
const orgTeamSub = orgSubscriptions.find(checkTeamPlan)
const orgProSub = orgSubscriptions.find(checkProPlan)
activeSubscription = orgEnterpriseSub || orgTeamSub || orgProSub || null
if (activeSubscription) break
}
}
}
let limit = 0
if (activeSubscription) {
limit = calculateUsageLimit(activeSubscription)
logger.info('Using calculated subscription limit', {
userId,
plan: activeSubscription.plan,
seats: activeSubscription.seats || 1,
limit,
})
} else {
limit = env.FREE_TIER_COST_LIMIT || 5
logger.info('Using free tier limit', { userId, limit })
}
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
if (statsRecords.length === 0) {
return false
}
const currentCost = Number.parseFloat(statsRecords[0].totalCost.toString())
logger.info('Checking cost limit', { userId, currentCost, limit })
return currentCost >= limit
} catch (error) {
logger.error('Error checking cost limit', { error, userId })
return false // Be conservative in case of error
}
}
export async function isSharingEnabled(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
if (activeDirectSubscription?.limits?.sharingEnabled) {
return true
}
const memberships = await db.select().from(member).where(eq(member.userId, userId))
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription?.limits?.sharingEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking sharing permission', { error, userId })
return false // Be conservative in case of error
}
}
export async function isMultiplayerEnabled(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
if (activeDirectSubscription?.limits?.multiplayerEnabled) {
return true
}
const memberships = await db.select().from(member).where(eq(member.userId, userId))
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription?.limits?.multiplayerEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking multiplayer permission', { error, userId })
return false // Be conservative in case of error
}
}
export async function isWorkspaceCollaborationEnabled(userId: string): Promise<boolean> {
try {
if (!isProd) {
return true
}
const { data: directSubscriptions } = await client.subscription.list({
query: { referenceId: userId },
})
const activeDirectSubscription = directSubscriptions?.find((sub) => sub.status === 'active')
if (activeDirectSubscription?.limits?.workspaceCollaborationEnabled) {
return true
}
const memberships = await db.select().from(member).where(eq(member.userId, userId))
// Check each organization for a subscription with workspace collaboration enabled
for (const membership of memberships) {
const { data: orgSubscriptions } = await client.subscription.list({
query: { referenceId: membership.organizationId },
})
const activeOrgSubscription = orgSubscriptions?.find((sub) => sub.status === 'active')
if (activeOrgSubscription?.limits?.workspaceCollaborationEnabled) {
return true
}
}
return false
} catch (error) {
logger.error('Error checking workspace collaboration permission', { error, userId })
return false // Be conservative in case of error
}
}
export async function getHighestPrioritySubscription(userId: string) {
const personalSubs = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active')))
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
const orgIds = memberships.map((m: { organizationId: string }) => m.organizationId)
let orgSubs: any[] = []
if (orgIds.length > 0) {
orgSubs = await db
.select()
.from(subscription)
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
}
const allSubs = [...personalSubs, ...orgSubs]
if (allSubs.length === 0) return null
const enterpriseSub = allSubs.find((s) => checkEnterprisePlan(s))
if (enterpriseSub) return enterpriseSub
const teamSub = allSubs.find((s) => checkTeamPlan(s))
if (teamSub) return teamSub
const proSub = allSubs.find((s) => checkProPlan(s))
if (proSub) return proSub
return null
}

View File

@@ -1,48 +0,0 @@
import { env } from '../env'
export function checkEnterprisePlan(subscription: any): boolean {
return subscription?.plan === 'enterprise' && subscription?.status === 'active'
}
export function checkProPlan(subscription: any): boolean {
return subscription?.plan === 'pro' && subscription?.status === 'active'
}
export function checkTeamPlan(subscription: any): boolean {
return subscription?.plan === 'team' && subscription?.status === 'active'
}
/**
* Calculate usage limit for a subscription based on its type and metadata
* @param subscription The subscription object
* @returns The calculated usage limit in dollars
*/
export function calculateUsageLimit(subscription: any): number {
if (!subscription || subscription.status !== 'active') {
return env.FREE_TIER_COST_LIMIT || 0
}
const seats = subscription.seats || 1
if (subscription.plan === 'pro') {
return env.PRO_TIER_COST_LIMIT || 0
}
if (subscription.plan === 'team') {
return seats * (env.TEAM_TIER_COST_LIMIT || 0)
}
if (subscription.plan === 'enterprise') {
const metadata = subscription.metadata || {}
if (metadata.perSeatAllowance) {
return seats * Number.parseFloat(metadata.perSeatAllowance)
}
if (metadata.totalAllowance) {
return Number.parseFloat(metadata.totalAllowance)
}
return seats * (env.ENTERPRISE_TIER_COST_LIMIT || 0)
}
return env.FREE_TIER_COST_LIMIT || 0
}

View File

@@ -1,152 +0,0 @@
import type { NextRequest } from 'next/server'
import { isProd } from '@/lib/environment'
import { getRedisClient } from '../redis'
// Configuration
const RATE_LIMIT_WINDOW = 60 // 1 minute window (in seconds)
const WAITLIST_MAX_REQUESTS = 5 // 5 requests per minute per IP
const WAITLIST_BLOCK_DURATION = 15 * 60 // 15 minutes block (in seconds)
// Fallback in-memory store for development or if Redis fails
const inMemoryStore = new Map<
string,
{ count: number; timestamp: number; blocked: boolean; blockedUntil?: number }
>()
// Clean up in-memory store periodically (only used in development)
if (!isProd && typeof setInterval !== 'undefined') {
setInterval(
() => {
const now = Math.floor(Date.now() / 1000)
for (const [key, data] of inMemoryStore.entries()) {
if (data.blocked && data.blockedUntil && data.blockedUntil < now) {
inMemoryStore.delete(key)
} else if (!data.blocked && now - data.timestamp > RATE_LIMIT_WINDOW) {
inMemoryStore.delete(key)
}
}
},
5 * 60 * 1000
)
}
// Get client IP from request
export function getClientIp(request: NextRequest): string {
const xff = request.headers.get('x-forwarded-for')
const realIp = request.headers.get('x-real-ip')
if (xff) {
const ips = xff.split(',')
return ips[0].trim()
}
return realIp || '0.0.0.0'
}
// Check if a request is rate limited
export async function isRateLimited(
request: NextRequest,
type: 'waitlist' = 'waitlist'
): Promise<{
limited: boolean
message?: string
remainingTime?: number
}> {
const clientIp = getClientIp(request)
const key = `ratelimit:${type}:${clientIp}`
const now = Math.floor(Date.now() / 1000)
// Get the shared Redis client
const redisClient = getRedisClient()
// Use Redis if available
if (redisClient) {
try {
// Check if IP is blocked
const isBlocked = await redisClient.get(`${key}:blocked`)
if (isBlocked) {
const ttl = await redisClient.ttl(`${key}:blocked`)
if (ttl > 0) {
return {
limited: true,
message: 'Too many requests. Please try again later.',
remainingTime: ttl,
}
}
// Block expired, remove it
await redisClient.del(`${key}:blocked`)
}
// Increment counter with expiry
const count = await redisClient.incr(key)
// Set expiry on first request
if (count === 1) {
await redisClient.expire(key, RATE_LIMIT_WINDOW)
}
// If limit exceeded, block the IP
if (count > WAITLIST_MAX_REQUESTS) {
await redisClient.set(`${key}:blocked`, '1', 'EX', WAITLIST_BLOCK_DURATION)
return {
limited: true,
message: 'Too many requests. Please try again later.',
remainingTime: WAITLIST_BLOCK_DURATION,
}
}
return { limited: false }
} catch (error) {
console.error('Redis rate limit error:', error)
// Fall back to in-memory if Redis fails
}
}
// In-memory fallback implementation
let record = inMemoryStore.get(key)
// Check if IP is blocked
if (record?.blocked) {
if (record.blockedUntil && record.blockedUntil < now) {
record = { count: 1, timestamp: now, blocked: false }
inMemoryStore.set(key, record)
return { limited: false }
}
const remainingTime = record.blockedUntil ? record.blockedUntil - now : WAITLIST_BLOCK_DURATION
return {
limited: true,
message: 'Too many requests. Please try again later.',
remainingTime,
}
}
// If no record exists or window expired, create/reset it
if (!record || now - record.timestamp > RATE_LIMIT_WINDOW) {
record = { count: 1, timestamp: now, blocked: false }
inMemoryStore.set(key, record)
return { limited: false }
}
// Increment counter
record.count++
// If limit exceeded, block the IP
if (record.count > WAITLIST_MAX_REQUESTS) {
record.blocked = true
record.blockedUntil = now + WAITLIST_BLOCK_DURATION
inMemoryStore.set(key, record)
return {
limited: true,
message: 'Too many requests. Please try again later.',
remainingTime: WAITLIST_BLOCK_DURATION,
}
}
inMemoryStore.set(key, record)
return { limited: false }
}

View File

@@ -1,646 +0,0 @@
import { and, count, desc, eq, inArray, like, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import {
getEmailSubject,
renderWaitlistApprovalEmail,
renderWaitlistConfirmationEmail,
} from '@/components/emails/render-email'
import { type EmailType, sendBatchEmails, sendEmail } from '@/lib/email/mailer'
import { createToken, verifyToken } from '@/lib/waitlist/token'
import { db } from '@/db'
import { waitlist } from '@/db/schema'
import { env } from '../env'
// Define types for better type safety
export type WaitlistStatus = 'pending' | 'approved' | 'rejected' | 'signed_up'
export interface WaitlistEntry {
id: string
email: string
status: WaitlistStatus
createdAt: Date
updatedAt: Date
}
// Helper function to find a user by email
async function findUserByEmail(email: string) {
const normalizedEmail = email.toLowerCase().trim()
const users = await db.select().from(waitlist).where(eq(waitlist.email, normalizedEmail)).limit(1)
return {
users,
user: users.length > 0 ? users[0] : null,
normalizedEmail,
}
}
// Add a user to the waitlist
export async function addToWaitlist(email: string): Promise<{ success: boolean; message: string }> {
try {
const { users, normalizedEmail } = await findUserByEmail(email)
if (users.length > 0) {
return {
success: false,
message: 'Email already exists in waitlist',
}
}
// Add to waitlist
await db.insert(waitlist).values({
id: nanoid(),
email: normalizedEmail,
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
})
// Send confirmation email
try {
const emailHtml = await renderWaitlistConfirmationEmail(normalizedEmail)
const subject = getEmailSubject('waitlist-confirmation')
await sendEmail({
to: normalizedEmail,
subject,
html: emailHtml,
})
} catch (emailError) {
console.error('Error sending confirmation email:', emailError)
// Continue even if email fails - user is still on waitlist
}
return {
success: true,
message: 'Successfully added to waitlist',
}
} catch (error) {
console.error('Error adding to waitlist:', error)
return {
success: false,
message: 'An error occurred while adding to waitlist',
}
}
}
// Get all waitlist entries with pagination and search
export async function getWaitlistEntries(
page = 1,
limit = 20,
status?: WaitlistStatus | 'all',
search?: string
) {
try {
const offset = (page - 1) * limit
// Build query conditions
let whereCondition
// First, determine if we need to apply status filter
const shouldFilterByStatus = status && status !== 'all'
// Now build the conditions
if (shouldFilterByStatus && search && search.trim()) {
// Both status and search
whereCondition = and(
eq(waitlist.status, status as string),
like(waitlist.email, `%${search.trim()}%`)
)
} else if (shouldFilterByStatus) {
// Only status
whereCondition = eq(waitlist.status, status as string)
} else if (search?.trim()) {
// Only search
whereCondition = like(waitlist.email, `%${search.trim()}%`)
} else {
whereCondition = null
}
// Get entries with conditions
let entries = []
if (whereCondition) {
entries = await db
.select()
.from(waitlist)
.where(whereCondition)
.limit(limit)
.offset(offset)
.orderBy(desc(waitlist.createdAt))
} else {
// Get all entries
entries = await db
.select()
.from(waitlist)
.limit(limit)
.offset(offset)
.orderBy(desc(waitlist.createdAt))
}
// Get total count for pagination with same conditions
let countResult = []
if (whereCondition) {
countResult = await db.select({ value: count() }).from(waitlist).where(whereCondition)
} else {
countResult = await db.select({ value: count() }).from(waitlist)
}
return {
entries,
total: countResult[0]?.value || 0,
page,
limit,
}
} catch (error) {
console.error('Error getting waitlist entries:', error)
throw error
}
}
// Approve a user from the waitlist and send approval email
export async function approveWaitlistUser(
email: string
): Promise<{ success: boolean; message: string; emailError?: any; rateLimited?: boolean }> {
try {
const { user, normalizedEmail } = await findUserByEmail(email)
if (!user) {
return {
success: false,
message: 'User not found in waitlist',
}
}
if (user.status === 'approved') {
return {
success: false,
message: 'User already approved',
}
}
// Create a special signup token
const token = await createToken({
email: normalizedEmail,
type: 'waitlist-approval',
expiresIn: '7d',
})
// Generate signup link with token
const signupLink = `${env.NEXT_PUBLIC_APP_URL}/signup?token=${token}`
// IMPORTANT: Send approval email BEFORE updating the status
// This ensures we don't mark users as approved if email fails
try {
const emailHtml = await renderWaitlistApprovalEmail(normalizedEmail, signupLink)
const subject = getEmailSubject('waitlist-approval')
const emailResult = await sendEmail({
to: normalizedEmail,
subject,
html: emailHtml,
emailType: 'updates',
})
// If email sending failed, don't update the user status
if (!emailResult.success) {
console.error('Error sending approval email:', emailResult.message)
// Check if it's a rate limit error
if (
emailResult.message?.toLowerCase().includes('rate') ||
emailResult.message?.toLowerCase().includes('too many') ||
emailResult.message?.toLowerCase().includes('limit')
) {
return {
success: false,
message: 'Rate limit exceeded for email sending',
rateLimited: true,
}
}
return {
success: false,
message: emailResult.message || 'Failed to send approval email',
emailError: emailResult,
}
}
// Email sent successfully, now update status to approved
await db
.update(waitlist)
.set({
status: 'approved',
updatedAt: new Date(),
})
.where(eq(waitlist.email, normalizedEmail))
return {
success: true,
message: 'User approved and email sent',
}
} catch (emailError) {
console.error('Error sending approval email:', emailError)
// Check if it's a rate limit error
if (
emailError instanceof Error &&
(emailError.message.toLowerCase().includes('rate') ||
emailError.message.toLowerCase().includes('too many') ||
emailError.message.toLowerCase().includes('limit'))
) {
return {
success: false,
message: 'Rate limit exceeded for email sending',
rateLimited: true,
}
}
return {
success: false,
message: 'Failed to send approval email',
emailError,
}
}
} catch (error) {
console.error('Error approving waitlist user:', error)
return {
success: false,
message: 'An error occurred while approving user',
}
}
}
// Reject a user from the waitlist
export async function rejectWaitlistUser(
email: string
): Promise<{ success: boolean; message: string }> {
try {
const { user, normalizedEmail } = await findUserByEmail(email)
if (!user) {
return {
success: false,
message: 'User not found in waitlist',
}
}
// Update status to rejected
await db
.update(waitlist)
.set({
status: 'rejected',
updatedAt: new Date(),
})
.where(eq(waitlist.email, normalizedEmail))
return {
success: true,
message: 'User rejected',
}
} catch (error) {
console.error('Error rejecting waitlist user:', error)
return {
success: false,
message: 'An error occurred while rejecting user',
}
}
}
// Check if a user is approved
export async function isUserApproved(email: string): Promise<boolean> {
try {
const { user } = await findUserByEmail(email)
return !!user && user.status === 'approved'
} catch (error) {
console.error('Error checking if user is approved:', error)
return false
}
}
// Verify waitlist token
export async function verifyWaitlistToken(
token: string
): Promise<{ valid: boolean; email?: string }> {
try {
// Verify token
const decoded = await verifyToken(token)
if (!decoded || decoded.type !== 'waitlist-approval') {
return { valid: false }
}
// Check if user is in the approved waitlist
const isApproved = await isUserApproved(decoded.email)
if (!isApproved) {
return { valid: false }
}
return {
valid: true,
email: decoded.email,
}
} catch (error) {
console.error('Error verifying waitlist token:', error)
return { valid: false }
}
}
// Mark a user as signed up after they create an account
export async function markWaitlistUserAsSignedUp(
email: string
): Promise<{ success: boolean; message: string }> {
try {
const { user, normalizedEmail } = await findUserByEmail(email)
if (!user) {
return {
success: false,
message: 'User not found in waitlist',
}
}
if (user.status !== 'approved') {
return {
success: false,
message: 'User is not in approved status',
}
}
// Update status to signed_up
await db
.update(waitlist)
.set({
status: 'signed_up',
updatedAt: new Date(),
})
.where(eq(waitlist.email, normalizedEmail))
return {
success: true,
message: 'User marked as signed up',
}
} catch (error) {
console.error('Error marking waitlist user as signed up:', error)
return {
success: false,
message: 'An error occurred while updating user status',
}
}
}
// Resend approval email to an already approved user
export async function resendApprovalEmail(
email: string
): Promise<{ success: boolean; message: string; emailError?: any; rateLimited?: boolean }> {
try {
const { user, normalizedEmail } = await findUserByEmail(email)
if (!user) {
return {
success: false,
message: 'User not found in waitlist',
}
}
if (user.status !== 'approved') {
return {
success: false,
message: 'User is not approved',
}
}
// Create a special signup token
const token = await createToken({
email: normalizedEmail,
type: 'waitlist-approval',
expiresIn: '7d',
})
// Generate signup link with token
const signupLink = `${env.NEXT_PUBLIC_APP_URL}/signup?token=${token}`
// Send approval email
try {
const emailHtml = await renderWaitlistApprovalEmail(normalizedEmail, signupLink)
const subject = getEmailSubject('waitlist-approval')
const emailResult = await sendEmail({
to: normalizedEmail,
subject,
html: emailHtml,
emailType: 'updates',
})
// Check for email sending failures
if (!emailResult.success) {
console.error('Error sending approval email:', emailResult.message)
// Check if it's a rate limit error
if (
emailResult.message?.toLowerCase().includes('rate') ||
emailResult.message?.toLowerCase().includes('too many') ||
emailResult.message?.toLowerCase().includes('limit')
) {
return {
success: false,
message: 'Rate limit exceeded for email sending',
rateLimited: true,
}
}
return {
success: false,
message: emailResult.message || 'Failed to send approval email',
emailError: emailResult,
}
}
return {
success: true,
message: 'Approval email resent successfully',
}
} catch (emailError) {
console.error('Error sending approval email:', emailError)
// Check if it's a rate limit error
if (
emailError instanceof Error &&
(emailError.message.toLowerCase().includes('rate') ||
emailError.message.toLowerCase().includes('too many') ||
emailError.message.toLowerCase().includes('limit'))
) {
return {
success: false,
message: 'Rate limit exceeded for email sending',
rateLimited: true,
}
}
return {
success: false,
message: 'Failed to send approval email',
emailError,
}
}
} catch (error) {
console.error('Error resending approval email:', error)
return {
success: false,
message: 'An error occurred while resending approval email',
}
}
}
// Approve multiple users from the waitlist and send approval emails in batches
export async function approveBatchWaitlistUsers(emails: string[]): Promise<{
success: boolean
message: string
results: Array<{ email: string; success: boolean; message: string }>
emailErrors?: any
rateLimited?: boolean
}> {
try {
if (!emails || emails.length === 0) {
return {
success: false,
message: 'No emails provided for batch approval',
results: [],
}
}
// Fetch all users from the waitlist that match the emails
const normalizedEmails = emails.map((email) => email.trim().toLowerCase())
const users = await db
.select()
.from(waitlist)
.where(
and(
inArray(waitlist.email, normalizedEmails),
// Only select users who aren't already approved
or(eq(waitlist.status, 'pending'), eq(waitlist.status, 'rejected'))
)
)
if (users.length === 0) {
return {
success: false,
message: 'No valid users found for approval',
results: emails.map((email) => ({
email,
success: false,
message: 'User not found or already approved',
})),
}
}
// Create email options for each user
const emailOptions = await Promise.all(
users.map(async (user) => {
// Create a special signup token
const token = await createToken({
email: user.email,
type: 'waitlist-approval',
expiresIn: '7d',
})
// Generate signup link with token
const signupLink = `${env.NEXT_PUBLIC_APP_URL}/signup?token=${token}`
// Generate email HTML
const emailHtml = await renderWaitlistApprovalEmail(user.email, signupLink)
const subject = getEmailSubject('waitlist-approval')
return {
to: user.email,
subject,
html: emailHtml,
emailType: 'updates' as EmailType,
}
})
)
// Send batch emails
const emailResults = await sendBatchEmails({ emails: emailOptions })
// Process results and update database
const results = users.map((user, index) => {
const emailResult = emailResults.results[index]
if (emailResult?.success) {
// Update user status to approved in database
return {
email: user.email,
success: true,
message: 'User approved and email sent successfully',
data: emailResult.data,
}
}
return {
email: user.email,
success: false,
message: emailResult?.message || 'Failed to send approval email',
error: emailResult,
}
})
// Update approved users in the database
const successfulEmails = results
.filter((result) => result.success)
.map((result) => result.email)
if (successfulEmails.length > 0) {
await db
.update(waitlist)
.set({
status: 'approved',
updatedAt: new Date(),
})
.where(
and(
inArray(waitlist.email, successfulEmails),
// Only update users who aren't already approved
or(eq(waitlist.status, 'pending'), eq(waitlist.status, 'rejected'))
)
)
}
// Check if any rate limit errors occurred
const rateLimitError = emailResults.results.some(
(result: { message?: string }) =>
result.message?.toLowerCase().includes('rate') ||
result.message?.toLowerCase().includes('too many') ||
result.message?.toLowerCase().includes('limit')
)
return {
success: successfulEmails.length > 0,
message:
successfulEmails.length === users.length
? 'All users approved successfully'
: successfulEmails.length > 0
? 'Some users approved successfully'
: 'Failed to approve any users',
results: results.map(
({ email, success, message }: { email: string; success: boolean; message: string }) => ({
email,
success,
message,
})
),
emailErrors: emailResults.results.some((r: { success: boolean }) => !r.success),
rateLimited: rateLimitError,
}
} catch (error) {
console.error('Error approving batch waitlist users:', error)
return {
success: false,
message: 'An error occurred while approving users',
results: emails.map((email) => ({
email,
success: false,
message: 'Operation failed due to server error',
})),
}
}
}

View File

@@ -1,54 +0,0 @@
import { jwtVerify, SignJWT } from 'jose'
import { nanoid } from 'nanoid'
import { env } from '../env'
interface TokenPayload {
email: string
type: 'waitlist-approval' | 'password-reset'
expiresIn: string
}
interface DecodedToken {
email: string
type: string
jti: string
iat: number
exp: number
}
// Get JWT secret from environment variables
const getJwtSecret = () => {
const secret = env.JWT_SECRET
if (!secret) {
throw new Error('JWT_SECRET environment variable is not set')
}
return new TextEncoder().encode(secret)
}
/**
* Create a JWT token
*/
export async function createToken({ email, type, expiresIn }: TokenPayload): Promise<string> {
const jwt = await new SignJWT({ email, type })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(expiresIn)
.setJti(nanoid())
.sign(getJwtSecret())
return jwt
}
/**
* Verify a JWT token
*/
export async function verifyToken(token: string): Promise<DecodedToken | null> {
try {
const { payload } = await jwtVerify(token, getJwtSecret())
return payload as unknown as DecodedToken
} catch (error) {
console.error('Error verifying token:', error)
return null
}
}

View File

@@ -22,7 +22,8 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"email:dev": "email dev --dir components/emails",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test:billing:suite": "bun run scripts/test-billing-suite.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",

View File

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

View File

@@ -9,6 +9,7 @@ import { useNotificationStore } from './notifications/store'
import { useConsoleStore } from './panel/console/store'
import { useVariablesStore } from './panel/variables/store'
import { useEnvironmentStore } from './settings/environment/store'
import { useSubscriptionStore } from './subscription/store'
import { useWorkflowRegistry } from './workflows/registry/store'
import { useSubBlockStore } from './workflows/subblock/store'
import { useWorkflowStore } from './workflows/workflow/store'
@@ -206,6 +207,7 @@ export {
useCustomToolsStore,
useVariablesStore,
useSubBlockStore,
useSubscriptionStore,
}
// Helper function to reset all stores
@@ -231,6 +233,7 @@ export const resetAllStores = () => {
useCopilotStore.setState({ messages: [], isSendingMessage: false, error: null })
useCustomToolsStore.setState({ tools: {} })
useVariablesStore.getState().resetLoaded() // Reset variables store tracking
useSubscriptionStore.getState().reset() // Reset subscription store
}
// Helper function to log all store states
@@ -246,6 +249,7 @@ export const logAllStores = () => {
customTools: useCustomToolsStore.getState(),
subBlock: useSubBlockStore.getState(),
variables: useVariablesStore.getState(),
subscription: useSubscriptionStore.getState(),
}
return state

View File

@@ -0,0 +1,21 @@
export { useOrganizationStore } from './store'
export type {
Invitation,
Member,
MemberUsageData,
Organization,
OrganizationBillingData,
OrganizationFormData,
OrganizationState,
OrganizationStore,
Subscription,
User,
Workspace,
WorkspaceInvitation,
} from './types'
export {
calculateSeatUsage,
generateSlug,
validateEmail,
validateSlug,
} from './utils'

View File

@@ -0,0 +1,832 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { client } from '@/lib/auth-client'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { createLogger } from '@/lib/logs/console-logger'
import type { OrganizationStore, Subscription, WorkspaceInvitation } from './types'
import { calculateSeatUsage, generateSlug, validateEmail, validateSlug } from './utils'
const logger = createLogger('OrganizationStore')
const CACHE_DURATION = 30 * 1000
export const useOrganizationStore = create<OrganizationStore>()(
devtools(
(set, get) => ({
organizations: [],
activeOrganization: null,
subscriptionData: null,
userWorkspaces: [],
organizationBillingData: null,
orgFormData: {
name: '',
slug: '',
logo: '',
},
isLoading: false,
isLoadingSubscription: false,
isLoadingOrgBilling: false,
isCreatingOrg: false,
isInviting: false,
isSavingOrgSettings: false,
error: null,
orgSettingsError: null,
inviteSuccess: false,
orgSettingsSuccess: null,
lastFetched: null,
lastSubscriptionFetched: null,
lastOrgBillingFetched: null,
hasTeamPlan: false,
hasEnterprisePlan: false,
loadData: async () => {
const state = get()
if (state.lastFetched && Date.now() - state.lastFetched < CACHE_DURATION) {
logger.debug('Using cached data')
return
}
if (state.isLoading) {
logger.debug('Data already loading, skipping duplicate request')
return
}
set({ isLoading: true, error: null })
try {
// Load organizations, active organization, and user subscription info in parallel
const [orgsResponse, activeOrgResponse, billingResponse] = await Promise.all([
client.organization.list(),
client.organization.getFullOrganization().catch(() => ({ data: null })),
fetch('/api/billing?context=user'),
])
const organizations = orgsResponse.data || []
const activeOrganization = activeOrgResponse.data || null
let hasTeamPlan = false
let hasEnterprisePlan = false
if (billingResponse.ok) {
const billingResult = await billingResponse.json()
const billingData = billingResult.data
hasTeamPlan = billingData.isTeam
hasEnterprisePlan = billingData.isEnterprise
}
set({
organizations,
activeOrganization,
hasTeamPlan,
hasEnterprisePlan,
isLoading: false,
error: null,
lastFetched: Date.now(),
})
logger.debug('Organization data loaded successfully', {
organizationCount: organizations.length,
activeOrganizationId: activeOrganization?.id,
hasTeamPlan,
hasEnterprisePlan,
})
// Load subscription data for the active organization
if (activeOrganization?.id) {
await get().loadOrganizationSubscription(activeOrganization.id)
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to load organization data'
logger.error('Failed to load organization data', { error })
set({
isLoading: false,
error: errorMessage,
})
}
},
loadOrganizationSubscription: async (orgId: string) => {
const state = get()
if (
state.subscriptionData &&
state.lastSubscriptionFetched &&
Date.now() - state.lastSubscriptionFetched < CACHE_DURATION
) {
logger.debug('Using cached subscription data')
return
}
if (state.isLoadingSubscription) {
logger.debug('Subscription data already loading, skipping duplicate request')
return
}
set({ isLoadingSubscription: true })
try {
logger.info('Loading subscription for organization', { orgId })
const { data, error } = await client.subscription.list({
query: { referenceId: orgId },
})
if (error) {
logger.error('Error fetching organization subscription', { error })
set({ error: 'Failed to load subscription data' })
return
}
// Find active team or enterprise subscription
const teamSubscription = data?.find(
(sub) => sub.status === 'active' && sub.plan === 'team'
)
const enterpriseSubscription = data?.find((sub) => checkEnterprisePlan(sub))
const activeSubscription = enterpriseSubscription || teamSubscription
if (activeSubscription) {
logger.info('Found active subscription', {
id: activeSubscription.id,
plan: activeSubscription.plan,
seats: activeSubscription.seats,
})
set({
subscriptionData: activeSubscription,
isLoadingSubscription: false,
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,
isLoadingSubscription: false,
lastSubscriptionFetched: Date.now(),
})
}
} catch (error) {
logger.error('Error loading subscription data', { error })
set({
error: error instanceof Error ? error.message : 'Failed to load subscription data',
isLoadingSubscription: false,
})
}
},
loadOrganizationBillingData: async (organizationId: string) => {
const state = get()
if (
state.organizationBillingData &&
state.lastOrgBillingFetched &&
Date.now() - state.lastOrgBillingFetched < CACHE_DURATION
) {
logger.debug('Using cached organization billing data')
return
}
if (state.isLoadingOrgBilling) {
logger.debug('Organization billing data already loading, skipping duplicate request')
return
}
set({ isLoadingOrgBilling: true })
try {
const response = await fetch(`/api/billing?context=organization&id=${organizationId}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
const data = result.data
set({
organizationBillingData: { ...data, userRole: result.userRole },
isLoadingOrgBilling: false,
lastOrgBillingFetched: Date.now(),
})
logger.debug('Organization billing data loaded successfully')
} catch (error) {
logger.error('Failed to load organization billing data', { error })
set({ isLoadingOrgBilling: false })
}
},
loadUserWorkspaces: async (userId?: string) => {
try {
// Get all workspaces the user is a member of
const workspacesResponse = await fetch('/api/workspaces')
if (!workspacesResponse.ok) {
logger.error('Failed to fetch workspaces')
return
}
const workspacesData = await workspacesResponse.json()
const allUserWorkspaces = workspacesData.workspaces || []
// Filter to only show workspaces where user has admin permissions
const adminWorkspaces = []
for (const workspace of allUserWorkspaces) {
try {
const permissionResponse = await fetch(`/api/workspaces/${workspace.id}/permissions`)
if (permissionResponse.ok) {
const permissionData = await permissionResponse.json()
// Check if current user has admin permission
// Use userId if provided, otherwise fall back to checking isOwner from workspace data
let hasAdminAccess = false
if (userId && permissionData.users) {
const currentUserPermission = permissionData.users.find(
(user: any) => user.id === userId || user.userId === userId
)
hasAdminAccess = currentUserPermission?.permissionType === 'admin'
}
// Also check if user is the workspace owner
const isOwner = workspace.isOwner || workspace.ownerId === userId
if (hasAdminAccess || isOwner) {
adminWorkspaces.push({
...workspace,
isOwner: isOwner,
canInvite: true,
})
}
}
} catch (error) {
logger.warn(`Failed to check permissions for workspace ${workspace.id}:`, error)
}
}
set({ userWorkspaces: adminWorkspaces })
logger.info('Loaded admin workspaces for invitation', {
total: allUserWorkspaces.length,
adminWorkspaces: adminWorkspaces.length,
userId: userId || 'not provided',
})
} catch (error) {
logger.error('Failed to load workspaces:', error)
}
},
refreshOrganization: async () => {
const { activeOrganization } = get()
if (!activeOrganization?.id) return
try {
const fullOrgResponse = await client.organization.getFullOrganization()
const updatedOrg = fullOrgResponse.data
set({ activeOrganization: updatedOrg })
// Also refresh subscription data
if (updatedOrg?.id) {
await get().loadOrganizationSubscription(updatedOrg.id)
}
} catch (error) {
logger.error('Failed to refresh organization data', { error })
set({
error: error instanceof Error ? error.message : 'Failed to refresh organization data',
})
}
},
// Organization management
createOrganization: async (name: string, slug: string) => {
set({ isCreatingOrg: true, error: null })
try {
logger.info('Creating team organization', { name, slug })
const result = await client.organization.create({ name, slug })
if (!result.data?.id) {
throw new Error('Failed to create organization')
}
const orgId = result.data.id
logger.info('Organization created', { orgId })
// Set as active organization
await client.organization.setActive({ organizationId: orgId })
// Handle subscription transfer if needed
const { hasTeamPlan, hasEnterprisePlan } = get()
if (hasTeamPlan || hasEnterprisePlan) {
await get().transferSubscriptionToOrganization(orgId)
}
// Refresh data
await get().loadData()
set({ isCreatingOrg: false })
} catch (error) {
logger.error('Failed to create organization', { error })
set({
error: error instanceof Error ? error.message : 'Failed to create organization',
isCreatingOrg: false,
})
}
},
setActiveOrganization: async (orgId: string) => {
set({ isLoading: true })
try {
await client.organization.setActive({ organizationId: orgId })
const activeOrgResponse = await client.organization.getFullOrganization()
const activeOrganization = activeOrgResponse.data
set({ activeOrganization })
if (activeOrganization?.id) {
await get().loadOrganizationSubscription(activeOrganization.id)
}
} catch (error) {
logger.error('Failed to set active organization', { error })
set({
error: error instanceof Error ? error.message : 'Failed to set active organization',
})
} finally {
set({ isLoading: false })
}
},
updateOrganizationSettings: async () => {
const { activeOrganization, orgFormData } = get()
if (!activeOrganization?.id) return
// Validate form
if (!orgFormData.name.trim()) {
set({ orgSettingsError: 'Organization name is required' })
return
}
if (!orgFormData.slug.trim()) {
set({ orgSettingsError: 'Organization slug is required' })
return
}
// Validate slug format
if (!validateSlug(orgFormData.slug)) {
set({
orgSettingsError:
'Slug can only contain lowercase letters, numbers, hyphens, and underscores',
})
return
}
set({ isSavingOrgSettings: true, orgSettingsError: null, orgSettingsSuccess: null })
try {
const response = await fetch(`/api/organizations/${activeOrganization.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: orgFormData.name.trim(),
slug: orgFormData.slug.trim(),
logo: orgFormData.logo.trim() || null,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to update organization settings')
}
set({ orgSettingsSuccess: 'Organization settings updated successfully' })
// Refresh organization data
await get().refreshOrganization()
// Clear success message after 3 seconds
setTimeout(() => {
set({ orgSettingsSuccess: null })
}, 3000)
} catch (error) {
logger.error('Failed to update organization settings', { error })
set({
orgSettingsError: error instanceof Error ? error.message : 'Failed to update settings',
})
} finally {
set({ isSavingOrgSettings: false })
}
},
// Team management
inviteMember: async (email: string, workspaceInvitations?: WorkspaceInvitation[]) => {
const { activeOrganization, subscriptionData } = get()
if (!activeOrganization) return
set({ isInviting: true, error: null, inviteSuccess: false })
try {
const { used: totalCount } = calculateSeatUsage(activeOrganization)
const seatLimit = subscriptionData?.seats || 0
if (totalCount >= seatLimit) {
throw new Error(
`You've reached your team seat limit of ${seatLimit}. Please upgrade your plan for more seats.`
)
}
if (!validateEmail(email)) {
throw new Error('Please enter a valid email address')
}
logger.info('Sending invitation to member', {
email,
organizationId: activeOrganization.id,
workspaceInvitations,
})
// Use direct API call with workspace invitations if selected
if (workspaceInvitations && workspaceInvitations.length > 0) {
const response = await fetch(
`/api/organizations/${activeOrganization.id}/invitations?batch=true`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
role: 'member',
workspaceInvitations,
}),
}
)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to send invitation')
}
} else {
// Use existing client method for organization-only invitations
const inviteResult = await client.organization.inviteMember({
email,
role: 'member',
organizationId: activeOrganization.id,
})
if (inviteResult.error) {
throw new Error(inviteResult.error.message || 'Failed to send invitation')
}
}
set({ inviteSuccess: true })
await get().refreshOrganization()
} catch (error) {
logger.error('Error inviting member', { error })
set({ error: error instanceof Error ? error.message : 'Failed to invite member' })
} finally {
set({ isInviting: false })
}
},
removeMember: async (memberId: string, shouldReduceSeats = false) => {
const { activeOrganization, subscriptionData } = get()
if (!activeOrganization) return
set({ isLoading: true })
try {
await client.organization.removeMember({
memberIdOrEmail: memberId,
organizationId: activeOrganization.id,
})
// If the user opted to reduce seats as well
if (shouldReduceSeats && subscriptionData) {
const currentSeats = subscriptionData.seats || 0
if (currentSeats > 1) {
await get().reduceSeats(currentSeats - 1)
}
}
await get().refreshOrganization()
} catch (error) {
logger.error('Failed to remove member', { error })
set({ error: error instanceof Error ? error.message : 'Failed to remove member' })
} finally {
set({ isLoading: false })
}
},
cancelInvitation: async (invitationId: string) => {
const { activeOrganization } = get()
if (!activeOrganization) return
set({ isLoading: true })
try {
await client.organization.cancelInvitation({ invitationId })
await get().refreshOrganization()
} catch (error) {
logger.error('Failed to cancel invitation', { error })
set({ error: error instanceof Error ? error.message : 'Failed to cancel invitation' })
} finally {
set({ isLoading: false })
}
},
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()
if (!activeOrganization || !subscriptionData) return
set({ isLoading: true, error: null })
try {
const { error } = await client.subscription.upgrade({
plan: 'team',
referenceId: activeOrganization.id,
subscriptionId: subscriptionData.id,
seats: newSeatCount,
successUrl: window.location.href,
cancelUrl: window.location.href,
})
if (error) {
throw new Error(error.message || 'Failed to update seats')
}
await get().refreshOrganization()
} catch (error) {
logger.error('Failed to add seats', { error })
set({ error: error instanceof Error ? error.message : 'Failed to update seats' })
} finally {
set({ isLoading: false })
}
},
reduceSeats: async (newSeatCount: number) => {
const { activeOrganization, subscriptionData } = get()
if (!activeOrganization || !subscriptionData) return
// Don't allow enterprise users to modify seats
if (checkEnterprisePlan(subscriptionData)) {
set({ error: 'Enterprise plan seats can only be modified by contacting support' })
return
}
if (newSeatCount <= 0) {
set({ error: 'Cannot reduce seats below 1' })
return
}
const { used: totalCount } = calculateSeatUsage(activeOrganization)
if (totalCount >= newSeatCount) {
set({
error: `You have ${totalCount} active members/invitations. Please remove members or cancel invitations before reducing seats.`,
})
return
}
set({ isLoading: true, error: null })
try {
const { error } = await client.subscription.upgrade({
plan: 'team',
referenceId: activeOrganization.id,
subscriptionId: subscriptionData.id,
seats: newSeatCount,
successUrl: window.location.href,
cancelUrl: window.location.href,
})
if (error) {
throw new Error(error.message || 'Failed to reduce seats')
}
await get().refreshOrganization()
} catch (error) {
logger.error('Failed to reduce seats', { error })
set({ error: error instanceof Error ? error.message : 'Failed to reduce seats' })
} finally {
set({ isLoading: false })
}
},
// Private helper method for subscription transfer
transferSubscriptionToOrganization: async (orgId: string) => {
const { hasTeamPlan, hasEnterprisePlan } = get()
try {
const userSubResponse = await client.subscription.list()
let teamSubscription: Subscription | null =
(userSubResponse.data?.find(
(sub) => (sub.plan === 'team' || sub.plan === 'enterprise') && sub.status === 'active'
) as Subscription | undefined) || null
// If no subscription found through client API but user has enterprise plan
if (!teamSubscription && hasEnterprisePlan) {
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) {
teamSubscription = {
id: `subscription_${Date.now()}`,
plan: billingData.data.plan,
status: billingData.data.status,
seats: billingData.data.seats,
referenceId: billingData.data.organizationId || 'unknown',
}
}
}
}
if (teamSubscription) {
const transferResponse = await fetch(
`/api/users/me/subscription/${teamSubscription.id}/transfer`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
organizationId: orgId,
}),
}
)
if (!transferResponse.ok) {
const errorText = await transferResponse.text()
let errorMessage = 'Failed to transfer subscription'
try {
if (errorText?.trim().startsWith('{')) {
const errorData = JSON.parse(errorText)
errorMessage = errorData.error || errorMessage
}
} catch (_e) {
errorMessage = errorText || errorMessage
}
throw new Error(errorMessage)
}
}
} catch (error) {
logger.error('Subscription transfer failed', { error })
throw error
}
},
// Computed getters (keep only those that are used)
getUserRole: (userEmail?: string) => {
const { activeOrganization } = get()
if (!userEmail || !activeOrganization?.members) {
return 'member'
}
const currentMember = activeOrganization.members.find((m) => m.user?.email === userEmail)
return currentMember?.role ?? 'member'
},
isAdminOrOwner: (userEmail?: string) => {
const role = get().getUserRole(userEmail)
return role === 'owner' || role === 'admin'
},
getUsedSeats: () => {
const { activeOrganization } = get()
return calculateSeatUsage(activeOrganization)
},
// Form handlers
setOrgFormData: (data) => {
set((state) => ({
orgFormData: { ...state.orgFormData, ...data },
}))
// Auto-generate slug from name if name is being set
if (data.name) {
const autoSlug = generateSlug(data.name)
set((state) => ({
orgFormData: { ...state.orgFormData, slug: autoSlug },
}))
}
},
// Utility methods
clearError: () => {
set({ error: null })
},
clearSuccessMessages: () => {
set({ inviteSuccess: false, orgSettingsSuccess: null })
},
reset: () => {
set({
organizations: [],
activeOrganization: null,
subscriptionData: null,
userWorkspaces: [],
organizationBillingData: null,
orgFormData: {
name: '',
slug: '',
logo: '',
},
isLoading: false,
isLoadingSubscription: false,
isLoadingOrgBilling: false,
isCreatingOrg: false,
isInviting: false,
isSavingOrgSettings: false,
error: null,
orgSettingsError: null,
inviteSuccess: false,
orgSettingsSuccess: null,
lastFetched: null,
lastSubscriptionFetched: null,
lastOrgBillingFetched: null,
hasTeamPlan: false,
hasEnterprisePlan: false,
})
},
}),
{ name: 'organization-store' }
)
)
// Auto-load organization data when store is first accessed
if (typeof window !== 'undefined') {
useOrganizationStore.getState().loadData()
}

View File

@@ -0,0 +1,169 @@
export interface User {
name?: string
email?: string
id?: string
}
export interface Member {
id: string
role: string
user?: User
}
export interface Invitation {
id: string
email: string
status: string
}
export interface Organization {
id: string
name: string
slug: string
logo?: string | null
members?: Member[]
invitations?: Invitation[]
createdAt: string | Date
[key: string]: unknown
}
export interface Subscription {
id: string
plan: string
status: string
seats?: number
referenceId: string
cancelAtPeriodEnd?: boolean
periodEnd?: number | Date
trialEnd?: number | Date
metadata?: any
[key: string]: unknown
}
export interface WorkspaceInvitation {
workspaceId: string
permission: string
}
export interface Workspace {
id: string
name: string
ownerId: string
isOwner: boolean
canInvite: boolean
}
export interface OrganizationFormData {
name: string
slug: string
logo: string
}
// Organization billing and usage types
export interface MemberUsageData {
userId: string
userName: string
userEmail: string
currentUsage: number
usageLimit: number
percentUsed: number
isOverLimit: boolean
role: string
joinedAt: string
lastActive: string | null
}
export interface OrganizationBillingData {
organizationId: string
organizationName: string
subscriptionPlan: string
subscriptionStatus: string
totalSeats: number
usedSeats: number
totalCurrentUsage: number
totalUsageLimit: number
averageUsagePerMember: number
billingPeriodStart: string | null
billingPeriodEnd: string | null
members?: MemberUsageData[]
userRole?: string
}
export interface OrganizationState {
// Core organization data
organizations: Organization[]
activeOrganization: Organization | null
// Team management
subscriptionData: Subscription | null
userWorkspaces: Workspace[]
// Organization billing and usage
organizationBillingData: OrganizationBillingData | null
// Organization settings
orgFormData: OrganizationFormData
// Loading states
isLoading: boolean
isLoadingSubscription: boolean
isLoadingOrgBilling: boolean
isCreatingOrg: boolean
isInviting: boolean
isSavingOrgSettings: boolean
// Error states
error: string | null
orgSettingsError: string | null
// Success states
inviteSuccess: boolean
orgSettingsSuccess: string | null
// Cache timestamps
lastFetched: number | null
lastSubscriptionFetched: number | null
lastOrgBillingFetched: number | null
// User permissions
hasTeamPlan: boolean
hasEnterprisePlan: boolean
}
export interface OrganizationStore extends OrganizationState {
loadData: () => Promise<void>
loadOrganizationSubscription: (orgId: string) => Promise<void>
loadOrganizationBillingData: (organizationId: string) => Promise<void>
loadUserWorkspaces: (userId?: string) => Promise<void>
refreshOrganization: () => Promise<void>
// Organization management
createOrganization: (name: string, slug: string) => Promise<void>
setActiveOrganization: (orgId: string) => Promise<void>
updateOrganizationSettings: () => Promise<void>
// Team management
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>
reduceSeats: (newSeatCount: number) => Promise<void>
transferSubscriptionToOrganization: (orgId: string) => Promise<void>
getUserRole: (userEmail?: string) => string
isAdminOrOwner: (userEmail?: string) => boolean
getUsedSeats: () => { used: number; members: number; pending: number }
setOrgFormData: (data: Partial<OrganizationFormData>) => void
clearError: () => void
clearSuccessMessages: () => void
}

View File

@@ -0,0 +1,36 @@
import type { Organization } from './types'
/**
* Calculate seat usage for an organization
*/
export function calculateSeatUsage(org?: Organization | null) {
const members = org?.members?.length ?? 0
const pending = org?.invitations?.filter((inv) => inv.status === 'pending').length ?? 0
return { used: members + pending, members, pending }
}
/**
* Generate a URL-friendly slug from a name
*/
export function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with hyphens
.replace(/-+/g, '-') // Replace consecutive hyphens with single hyphen
.replace(/^-|-$/g, '') // Remove leading and trailing hyphens
}
/**
* Validate organization slug format
*/
export function validateSlug(slug: string): boolean {
const slugRegex = /^[a-z0-9-_]+$/
return slugRegex.test(slug)
}
/**
* Validate email format
*/
export function validateEmail(email: string): boolean {
return email.includes('@') && email.trim().length > 0
}

Some files were not shown because too many files have changed in this diff Show More