diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 738e97603..5c126fb32 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -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 diff --git a/apps/sim/app/(landing)/components/waitlist-form.tsx b/apps/sim/app/(landing)/components/waitlist-form.tsx deleted file mode 100644 index d47b67d33..000000000 --- a/apps/sim/app/(landing)/components/waitlist-form.tsx +++ /dev/null @@ -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(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 ( -
-
- setEmail(e.target.value)} - disabled={isSubmitting || status === 'ratelimited'} - /> - -
-
- ) -} diff --git a/apps/sim/app/api/billing/daily/route.ts b/apps/sim/app/api/billing/daily/route.ts new file mode 100644 index 000000000..4740e4eb4 --- /dev/null +++ b/apps/sim/app/api/billing/daily/route.ts @@ -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 } + ) + } +} diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts new file mode 100644 index 000000000..4d134bc87 --- /dev/null +++ b/apps/sim/app/api/billing/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/billing/webhooks/stripe/route.ts b/apps/sim/app/api/billing/webhooks/stripe/route.ts new file mode 100644 index 000000000..8d150286d --- /dev/null +++ b/apps/sim/app/api/billing/webhooks/stripe/route.ts @@ -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'], + }) +} diff --git a/apps/sim/app/api/chat/[subdomain]/otp/route.ts b/apps/sim/app/api/chat/[subdomain]/otp/route.ts index 78a7116e5..d43ff967d 100644 --- a/apps/sim/app/api/chat/[subdomain]/otp/route.ts +++ b/apps/sim/app/api/chat/[subdomain]/otp/route.ts @@ -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'}`, diff --git a/apps/sim/app/api/logs/cleanup/route.ts b/apps/sim/app/api/logs/cleanup/route.ts index 4333d3769..baf1a8853 100644 --- a/apps/sim/app/api/logs/cleanup/route.ts +++ b/apps/sim/app/api/logs/cleanup/route.ts @@ -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) { diff --git a/apps/sim/app/api/marketplace/[id]/info/route.ts b/apps/sim/app/api/marketplace/[id]/info/route.ts index 5d227922b..6df13ec52 100644 --- a/apps/sim/app/api/marketplace/[id]/info/route.ts +++ b/apps/sim/app/api/marketplace/[id]/info/route.ts @@ -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]) diff --git a/apps/sim/app/api/marketplace/[id]/unpublish/route.ts b/apps/sim/app/api/marketplace/[id]/unpublish/route.ts index 9e96b10a2..c36339195 100644 --- a/apps/sim/app/api/marketplace/[id]/unpublish/route.ts +++ b/apps/sim/app/api/marketplace/[id]/unpublish/route.ts @@ -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( diff --git a/apps/sim/app/api/marketplace/[id]/view/route.ts b/apps/sim/app/api/marketplace/[id]/view/route.ts index c824f631d..4a4e51286 100644 --- a/apps/sim/app/api/marketplace/[id]/view/route.ts +++ b/apps/sim/app/api/marketplace/[id]/view/route.ts @@ -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}`) diff --git a/apps/sim/app/api/marketplace/workflows/route.ts b/apps/sim/app/api/marketplace/workflows/route.ts index fdf7a3cdb..ad5c66c38 100644 --- a/apps/sim/app/api/marketplace/workflows/route.ts +++ b/apps/sim/app/api/marketplace/workflows/route.ts @@ -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}`) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts new file mode 100644 index 000000000..16182295a --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts new file mode 100644 index 000000000..d9b7d0c2d --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts new file mode 100644 index 000000000..70bc98fc6 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts new file mode 100644 index 000000000..f2ef30750 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/route.ts @@ -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 diff --git a/apps/sim/app/api/organizations/[id]/workspaces/route.ts b/apps/sim/app/api/organizations/[id]/workspaces/route.ts new file mode 100644 index 000000000..52cc5ff5c --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/workspaces/route.ts @@ -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 } + ) + } +} diff --git a/apps/sim/app/api/organizations/invitations/accept/route.ts b/apps/sim/app/api/organizations/invitations/accept/route.ts new file mode 100644 index 000000000..c2a839afb --- /dev/null +++ b/apps/sim/app/api/organizations/invitations/accept/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 45dfedb83..56d0ea393 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -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' diff --git a/apps/sim/app/api/usage-limits/route.ts b/apps/sim/app/api/usage-limits/route.ts new file mode 100644 index 000000000..4726b0fde --- /dev/null +++ b/apps/sim/app/api/usage-limits/route.ts @@ -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=&organizationId= + * + */ +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 }) + } +} diff --git a/apps/sim/app/api/user/stats/route.ts b/apps/sim/app/api/user/stats/route.ts deleted file mode 100644 index e6e06570a..000000000 --- a/apps/sim/app/api/user/stats/route.ts +++ /dev/null @@ -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 }) - } -} diff --git a/apps/sim/app/api/user/subscription/[id]/seats/route.test.ts b/apps/sim/app/api/user/subscription/[id]/seats/route.test.ts deleted file mode 100644 index 4d0bdfe88..000000000 --- a/apps/sim/app/api/user/subscription/[id]/seats/route.test.ts +++ /dev/null @@ -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() - }) - }) -}) diff --git a/apps/sim/app/api/user/subscription/[id]/seats/route.ts b/apps/sim/app/api/user/subscription/[id]/seats/route.ts deleted file mode 100644 index e6f8fd641..000000000 --- a/apps/sim/app/api/user/subscription/[id]/seats/route.ts +++ /dev/null @@ -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 }) - } -} diff --git a/apps/sim/app/api/user/subscription/enterprise/route.ts b/apps/sim/app/api/user/subscription/enterprise/route.ts deleted file mode 100644 index 92f294e42..000000000 --- a/apps/sim/app/api/user/subscription/enterprise/route.ts +++ /dev/null @@ -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 } - ) - } -} diff --git a/apps/sim/app/api/user/subscription/route.ts b/apps/sim/app/api/user/subscription/route.ts deleted file mode 100644 index 5fe79b89d..000000000 --- a/apps/sim/app/api/user/subscription/route.ts +++ /dev/null @@ -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 }) - } -} diff --git a/apps/sim/app/api/user/usage/route.ts b/apps/sim/app/api/user/usage/route.ts deleted file mode 100644 index c2644f9b5..000000000 --- a/apps/sim/app/api/user/usage/route.ts +++ /dev/null @@ -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 }) - } -} diff --git a/apps/sim/app/api/user/api-keys/[id]/route.ts b/apps/sim/app/api/users/me/api-keys/[id]/route.ts similarity index 96% rename from apps/sim/app/api/user/api-keys/[id]/route.ts rename to apps/sim/app/api/users/me/api-keys/[id]/route.ts index b7da24e52..811a1d794 100644 --- a/apps/sim/app/api/user/api-keys/[id]/route.ts +++ b/apps/sim/app/api/users/me/api-keys/[id]/route.ts @@ -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 }> } diff --git a/apps/sim/app/api/user/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts similarity index 95% rename from apps/sim/app/api/user/api-keys/route.ts rename to apps/sim/app/api/users/me/api-keys/route.ts index 5bdb7083e..70b3f05d2 100644 --- a/apps/sim/app/api/user/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -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() diff --git a/apps/sim/app/api/user/settings/route.ts b/apps/sim/app/api/users/me/settings/route.ts similarity index 100% rename from apps/sim/app/api/user/settings/route.ts rename to apps/sim/app/api/users/me/settings/route.ts diff --git a/apps/sim/app/api/user/settings/unsubscribe/route.ts b/apps/sim/app/api/users/me/settings/unsubscribe/route.ts similarity index 100% rename from apps/sim/app/api/user/settings/unsubscribe/route.ts rename to apps/sim/app/api/users/me/settings/unsubscribe/route.ts diff --git a/apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts similarity index 100% rename from apps/sim/app/api/user/subscription/[id]/transfer/route.test.ts rename to apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts diff --git a/apps/sim/app/api/user/subscription/[id]/transfer/route.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts similarity index 100% rename from apps/sim/app/api/user/subscription/[id]/transfer/route.ts rename to apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts diff --git a/apps/sim/app/api/waitlist/route.ts b/apps/sim/app/api/waitlist/route.ts deleted file mode 100644 index cf3e0e789..000000000 --- a/apps/sim/app/api/waitlist/route.ts +++ /dev/null @@ -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 } - ) - } -} diff --git a/apps/sim/app/api/webhooks/poll/gmail/route.ts b/apps/sim/app/api/webhooks/poll/gmail/route.ts index c284c5199..231dedfbf 100644 --- a/apps/sim/app/api/webhooks/poll/gmail/route.ts +++ b/apps/sim/app/api/webhooks/poll/gmail/route.ts @@ -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 diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 76d06b483..34e78b7a5 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -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, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 0b5db45cc..bcd4c9545 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -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 { diff --git a/apps/sim/app/api/workflows/public/[id]/route.ts b/apps/sim/app/api/workflows/public/[id]/route.ts index 0762d7a01..9aae95192 100644 --- a/apps/sim/app/api/workflows/public/[id]/route.ts +++ b/apps/sim/app/api/workflows/public/[id]/route.ts @@ -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) diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index a84c3f787..38ea6b555 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -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 } diff --git a/apps/sim/app/unsubscribe/page.tsx b/apps/sim/app/unsubscribe/page.tsx index 6a560865d..cc3819a33 100644 --- a/apps/sim/app/unsubscribe/page.tsx +++ b/apps/sim/app/unsubscribe/page.tsx @@ -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', diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/filters.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/filters.tsx index 853ce8bd5..95c228d3d 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/filters.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/filters.tsx @@ -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() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx index 1f31269d1..bedc16139 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deploy-form/deploy-form.tsx @@ -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', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx index 8595d333f..74de9db5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx @@ -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() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index 2340bfb32..7f65143ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -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 { 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 = { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx index b6884054b..780fdcae1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx @@ -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', }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx index 50441d472..12d9b7c4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx @@ -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 } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/billing-summary.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/billing-summary.tsx new file mode 100644 index 000000000..059a337ae --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/billing-summary.tsx @@ -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(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( + + + Limit Exceeded + + ) + } + if (billingSummary.isWarning) { + return ( + + + Approaching Limit + + ) + } + return null + } + + const formatCurrency = (amount: number) => `$${amount.toFixed(2)}` + + if (isLoading || error || !billingSummary) { + return null + } + + return ( +
+ {/* Status Badge */} + {getStatusBadge()} + + {/* Billing Details */} + {showDetails && ( +
+
+ Plan minimum: + {formatCurrency(billingSummary.planMinimum)} +
+
+ Projected charge: + {formatCurrency(billingSummary.projectedCharge)} +
+ {billingSummary.organizationData && ( +
+ Team seats: + {billingSummary.organizationData.seatCount} +
+ )} +
+ )} +
+ ) +} + +export type { BillingSummaryData } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx new file mode 100644 index 000000000..246760f32 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx @@ -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(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 ( + <> +
+
+
+ Cancel Subscription +

+ You'll keep access until {formatDate(periodEndDate)} +

+
+ +
+ + {error && ( + + {error} + + )} +
+ + + + + Cancel {subscription.plan} subscription? + + You'll be redirected to Stripe to manage your subscription. You'll keep access until{' '} + {formatDate(periodEndDate)}, then downgrade to free plan. + + + +
+
+
    +
  • โ€ข Keep all features until {formatDate(periodEndDate)}
  • +
  • โ€ข No more charges
  • +
  • โ€ข Data preserved
  • +
  • โ€ข Can reactivate anytime
  • +
+
+
+ + + + + +
+
+ + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx new file mode 100644 index 000000000..e48f8f22c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx @@ -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 + isLoading: boolean + planType?: string +} + +export function EditMemberLimitDialog({ + open, + onOpenChange, + member, + onSave, + isLoading, + planType = 'team', +}: EditMemberLimitDialogProps) { + const [limitValue, setLimitValue] = useState('') + const [error, setError] = useState(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 ( + + + + + + Edit Usage Limit + + + Adjust the monthly usage limit for {member.userName} + + + +
+ {/* Member Info */} +
+
+ {member.userName.charAt(0).toUpperCase()} +
+
+
{member.userName}
+
{member.userEmail}
+
+ {member.role} +
+ + {/* Current Usage Stats */} +
+
+
Current Usage
+
{formatCurrency(member.currentUsage)}
+
+
+
Current Limit
+
{formatCurrency(member.usageLimit)}
+
+
+
Plan Minimum
+
+ {formatCurrency(planMinimum)} +
+
+
+ + {/* New Limit Input */} +
+ +
+ + setLimitValue(e.target.value)} + className='pl-9' + min={planMinimum} + max={10000} + step='1' + placeholder={planMinimum.toString()} + /> +
+

+ Minimum limit for {planType} plan: ${planMinimum} +

+
+ + {/* Change Indicator */} + {limitValue && !Number.isNaN(newLimit) && limitDifference > 0 && ( +
+
+ {isIncrease ? 'โ†—' : 'โ†˜'} + {isIncrease ? 'Increasing' : 'Decreasing'} limit by{' '} + {formatCurrency(limitDifference)} +
+
+ {isIncrease + ? 'This will give the member more usage allowance.' + : "This will reduce the member's usage allowance."} +
+
+ )} + + {/* Warning for below plan minimum */} + {newLimit < planMinimum && newLimit > 0 && ( + + + + The limit cannot be below the {planType} plan minimum of{' '} + {formatCurrency(planMinimum)}. + + + )} + + {/* Warning for decreasing below current usage */} + {newLimit < member.currentUsage && newLimit >= planMinimum && ( + + + + The new limit is below the member's current usage. The limit must be at least{' '} + {formatCurrency(member.currentUsage)}. + + + )} + + {/* Error Display */} + {error && ( + + + {error} + + )} +
+ + + + + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/team-usage-overview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/team-usage-overview.tsx new file mode 100644 index 000000000..e940ad162 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/team-usage-overview.tsx @@ -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(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 => { + 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 ( +
+ {/* Table Skeleton */} + + +
+ {/* Table Header Skeleton */} +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + {/* Table Body Skeleton */} +
+ {[...Array(3)].map((_, index) => ( +
+
+ {/* Member Info Skeleton */} +
+
+ +
+ + +
+
+ + {/* Mobile-only usage info skeleton */} +
+
+ + +
+
+ + +
+
+
+ + {/* Role Skeleton */} +
+ +
+ + {/* Usage - Desktop Skeleton */} +
+ +
+ + {/* Limit - Desktop Skeleton */} +
+ +
+ + {/* Last Active - Desktop Skeleton */} +
+ +
+ + {/* Actions Skeleton */} +
+ +
+
+
+ ))} +
+
+ + +
+ ) + } + + if (error) { + return ( + + + Error + {error} + + ) + } + + if (!billingData) { + return ( + + + No Data + No billing data available for this organization. + + ) + } + + const membersOverLimit = billingData.members?.filter((m) => m.isOverLimit).length || 0 + const membersNearLimit = + billingData.members?.filter((m) => !m.isOverLimit && m.percentUsed >= 80).length || 0 + + return ( +
+ {/* Alerts */} + {membersOverLimit > 0 && ( +
+
+
+ +
+
+

Usage Limits Exceeded

+

+ {membersOverLimit} team {membersOverLimit === 1 ? 'member has' : 'members have'}{' '} + exceeded their usage limits. Consider increasing their limits below. +

+
+
+
+ )} + + {/* Member Usage Table */} + + +
+ {/* Table Header */} +
+
+
Member
+
Role
+
Usage
+
Limit
+
Active
+
+
+
+ + {/* Table Body */} +
+ {billingData.members && billingData.members.length > 0 ? ( + billingData.members.map((member) => ( +
+
+ {/* Member Info */} +
+
+
+ {member.userName.charAt(0).toUpperCase()} +
+
+
{member.userName}
+
+ {member.userEmail} +
+
+
+ + {/* Mobile-only usage info */} +
+
+
Usage
+
+ {formatCurrency(member.currentUsage)} +
+
+
+
Limit
+
+ {formatCurrency(member.usageLimit)} +
+
+
+
+ + {/* Role */} +
+ + {member.role} + +
+ + {/* Usage - Desktop */} +
+
+ {formatCurrency(member.currentUsage)} +
+
+ + {/* Limit - Desktop */} +
+
+ {formatCurrency(member.usageLimit)} +
+
+ + {/* Last Active - Desktop */} +
+
+ {formatDate(member.lastActive)} +
+
+ + {/* Actions */} +
+ {hasAdminAccess && ( + + )} +
+
+
+ )) + ) : ( +
+
No team members found.
+
+ )} +
+
+ + + + {/* Edit Member Limit Dialog */} + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx new file mode 100644 index 000000000..e092a958d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx @@ -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 ( +
+ $ + {canEdit ? ( + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleSubmit} + className='h-8 w-20 font-medium text-sm' + min={minimumLimit} + step='1' + disabled={isSaving} + /> + ) : ( + {currentLimit} + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index 406f0db28..7157453e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -1,904 +1,419 @@ -import { useEffect, useState } from 'react' -import { AlertCircle } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { AlertCircle, Users } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' -import { Progress } from '@/components/ui/progress' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' -import { useActiveOrganization, useSession, useSubscription } from '@/lib/auth-client' -import { env } from '@/lib/env' +import { useSession, useSubscription } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console-logger' +import { useOrganizationStore } from '@/stores/organization' +import { useSubscriptionStore } from '@/stores/subscription/store' +import { BillingSummary } from './components/billing-summary' +import { CancelSubscription } from './components/cancel-subscription' import { TeamSeatsDialog } from './components/team-seats-dialog' +import { UsageLimitEditor } from './components/usage-limit-editor' const logger = createLogger('Subscription') interface SubscriptionProps { onOpenChange: (open: boolean) => void - cachedIsPro?: boolean - cachedIsTeam?: boolean - cachedIsEnterprise?: boolean - cachedUsageData?: any - cachedSubscriptionData?: any - isLoading?: boolean } -const useSubscriptionData = ( - userId: string | null | undefined, - activeOrgId: string | null | undefined, - cachedIsPro?: boolean, - cachedIsTeam?: boolean, - cachedIsEnterprise?: boolean, - cachedUsageData?: any, - cachedSubscriptionData?: any, - isParentLoading?: boolean -) => { - const [isPro, setIsPro] = useState(cachedIsPro || false) - const [isTeam, setIsTeam] = useState(cachedIsTeam || false) - const [isEnterprise, setIsEnterprise] = useState(cachedIsEnterprise || false) - const [usageData, setUsageData] = useState<{ - percentUsed: number - isWarning: boolean - isExceeded: boolean - currentUsage: number - limit: number - }>( - cachedUsageData || { - percentUsed: 0, - isWarning: false, - isExceeded: false, - currentUsage: 0, - limit: 0, - } - ) - const [subscriptionData, setSubscriptionData] = useState(cachedSubscriptionData || null) - const [loading, setLoading] = useState( - isParentLoading !== undefined ? isParentLoading : true - ) - const [error, setError] = useState(null) - const subscription = useSubscription() - - useEffect(() => { - if ( - isParentLoading !== undefined || - (cachedIsPro !== undefined && - cachedIsTeam !== undefined && - cachedIsEnterprise !== undefined && - cachedUsageData && - cachedSubscriptionData) - ) { - if (cachedIsPro !== undefined) setIsPro(cachedIsPro) - if (cachedIsTeam !== undefined) setIsTeam(cachedIsTeam) - if (cachedIsEnterprise !== undefined) setIsEnterprise(cachedIsEnterprise) - if (cachedUsageData) setUsageData(cachedUsageData) - if (cachedSubscriptionData) setSubscriptionData(cachedSubscriptionData) - if (isParentLoading !== undefined) setLoading(isParentLoading) - return - } - - async function loadSubscriptionData() { - if (!userId) return - - try { - setLoading(true) - setError(null) - - // Fetch subscription status and usage data in parallel - const [proStatusResponse, usageResponse] = await Promise.all([ - fetch('/api/user/subscription'), - fetch('/api/user/usage'), - ]) - - if (!proStatusResponse.ok) { - throw new Error('Failed to fetch subscription status') - } - if (!usageResponse.ok) { - throw new Error('Failed to fetch usage data') - } - - // Process the responses - const proStatusData = await proStatusResponse.json() - setIsPro(proStatusData.isPro) - setIsTeam(proStatusData.isTeam) - setIsEnterprise(proStatusData.isEnterprise) - - const usageDataResponse = await usageResponse.json() - setUsageData(usageDataResponse) - - logger.info('Subscription status and usage data retrieved', { - isPro: proStatusData.isPro, - isTeam: proStatusData.isTeam, - isEnterprise: proStatusData.isEnterprise, - usage: usageDataResponse, - }) - - // Main subscription logic - prioritize organization team/enterprise subscription - let activeSubscription = null - - // First check if user has an active organization with a team/enterprise subscription - if (activeOrgId) { - logger.info('Checking organization subscription first', { orgId: activeOrgId }) - - // Get the organization's subscription - const result = await subscription.list({ - query: { referenceId: activeOrgId }, - }) - - const orgSubscriptions = result.data - const orgSubError = 'error' in result ? result.error : null - - if (orgSubError) { - logger.error('Error fetching organization subscription details', orgSubError) - } else if (orgSubscriptions) { - // Find active team/enterprise subscription for the organization - activeSubscription = orgSubscriptions.find( - (sub) => sub.status === 'active' && (sub.plan === 'team' || sub.plan === 'enterprise') - ) - - if (activeSubscription) { - logger.info(`Using organization ${activeSubscription.plan} subscription as primary`, { - id: activeSubscription.id, - seats: activeSubscription.seats, - }) - } - } - } - - // If no org subscription was found, check for personal subscription - if (!activeSubscription) { - // Fetch detailed subscription data for the user - const result = await subscription.list() - - const userSubscriptions = result.data - const userSubError = 'error' in result ? result.error : null - - if (userSubError) { - logger.error('Error fetching user subscription details', userSubError) - } else if (userSubscriptions) { - // Find active subscription for the user - activeSubscription = userSubscriptions.find((sub) => sub.status === 'active') - } - } - - // If no subscription found via client.subscription but we know they have enterprise, - // try fetching from the enterprise endpoint - if (!activeSubscription && proStatusData.isEnterprise) { - try { - const enterpriseResponse = await fetch('/api/user/subscription/enterprise') - if (enterpriseResponse.ok) { - const enterpriseData = await enterpriseResponse.json() - if (enterpriseData.subscription) { - activeSubscription = enterpriseData.subscription - logger.info('Found enterprise subscription', { - id: activeSubscription.id, - plan: 'enterprise', - seats: activeSubscription.seats, - }) - } - } - } catch (error) { - logger.error('Error fetching enterprise subscription details', error) - } - } - - if (activeSubscription) { - logger.info('Using active subscription', { - id: activeSubscription.id, - plan: activeSubscription.plan, - status: activeSubscription.status, - }) - - setSubscriptionData(activeSubscription) - } else { - logger.warn('No active subscription found') - } - } catch (error) { - logger.error('Error checking subscription status:', error) - setError('Failed to load subscription data') - } finally { - setLoading(false) - } - } - - loadSubscriptionData() - }, [ - userId, - activeOrgId, - subscription, - cachedIsPro, - cachedIsTeam, - cachedIsEnterprise, - cachedUsageData, - cachedSubscriptionData, - isParentLoading, - ]) - - return { isPro, isTeam, isEnterprise, usageData, subscriptionData, loading, error } -} - -export function Subscription({ - onOpenChange, - cachedIsPro, - cachedIsTeam, - cachedIsEnterprise, - cachedUsageData, - cachedSubscriptionData, - isLoading, -}: SubscriptionProps) { +export function Subscription({ onOpenChange }: SubscriptionProps) { const { data: session } = useSession() - const { data: activeOrg } = useActiveOrganization() - const subscription = useSubscription() + const betterAuthSubscription = useSubscription() const { - isPro, - isTeam, - isEnterprise, - usageData, + isLoading, + error, + getSubscriptionStatus, + getUsage, + getBillingStatus, + usageLimitData, subscriptionData, - loading, - error: subscriptionError, - } = useSubscriptionData( - session?.user?.id, - activeOrg?.id, - cachedIsPro, - cachedIsTeam, - cachedIsEnterprise, - cachedUsageData, - cachedSubscriptionData, - isLoading + } = useSubscriptionStore() + + const { + activeOrganization, + organizationBillingData, + isLoadingOrgBilling, + loadOrganizationBillingData, + getUserRole, + addSeats, + } = useOrganizationStore() + + const [isSeatsDialogOpen, setIsSeatsDialogOpen] = useState(false) + const [isUpdatingSeats, setIsUpdatingSeats] = useState(false) + + const subscription = getSubscriptionStatus() + const usage = getUsage() + const billingStatus = getBillingStatus() + const activeOrgId = activeOrganization?.id + + useEffect(() => { + if (subscription.isTeam && activeOrgId) { + loadOrganizationBillingData(activeOrgId) + } + }, [activeOrgId, subscription.isTeam]) + + // Determine if user is team admin/owner + const userRole = getUserRole(session?.user?.email) + const isTeamAdmin = ['owner', 'admin'].includes(userRole) + const shouldShowOrgBilling = subscription.isTeam && isTeamAdmin && organizationBillingData + + const handleUpgrade = useCallback( + async (targetPlan: 'pro' | 'team') => { + if (!session?.user?.id) return + + // Get current subscription data including stripeSubscriptionId + const subscriptionData = useSubscriptionStore.getState().subscriptionData + const currentSubscriptionId = subscriptionData?.stripeSubscriptionId + + let referenceId = session.user.id + if (subscription.isTeam && activeOrgId) { + referenceId = activeOrgId + } + + const currentUrl = window.location.origin + window.location.pathname + + try { + const upgradeParams: any = { + plan: targetPlan, + referenceId, + successUrl: currentUrl, + cancelUrl: currentUrl, + seats: targetPlan === 'team' ? 1 : undefined, + } + + // Add subscriptionId if we have an existing subscription to ensure proper plan switching + if (currentSubscriptionId) { + upgradeParams.subscriptionId = currentSubscriptionId + logger.info('Upgrading existing subscription', { + targetPlan, + currentSubscriptionId, + referenceId, + }) + } else { + logger.info('Creating new subscription (no existing subscription found)', { + targetPlan, + referenceId, + }) + } + + await betterAuthSubscription.upgrade(upgradeParams) + } catch (error) { + logger.error('Failed to initiate subscription upgrade:', error) + alert('Failed to initiate upgrade. Please try again or contact support.') + } + }, + [session?.user?.id, subscription.isTeam, activeOrgId, betterAuthSubscription] ) - const [isCanceling, setIsCanceling] = useState(false) - const [error, setError] = useState(null) - const [isTeamDialogOpen, setIsTeamDialogOpen] = useState(false) - const [seats, setSeats] = useState(1) - const [isUpgradingTeam, setIsUpgradingTeam] = useState(false) - const [isUpgrading, setIsUpgrading] = useState(false) - - // Set error from subscription hook if there is one - useEffect(() => { - if (subscriptionError) { - setError(subscriptionError) - } - }, [subscriptionError]) - - const handleUpgrade = async (plan: string) => { - if (!session?.user) { - setError('You need to be logged in to upgrade your subscription') - return - } - - setIsUpgrading(true) - setError(null) - - try { - const result = await subscription.upgrade({ - plan: plan, - successUrl: window.location.href, - cancelUrl: window.location.href, - }) - - if ('error' in result && result.error) { - setError(result.error.message || `There was an error upgrading to the ${plan} plan`) - logger.error('Subscription upgrade error:', result.error) + const handleSeatsUpdate = useCallback( + async (seats: number) => { + if (!activeOrgId) { + logger.error('No active organization found for seat update') + return } - } catch (error: any) { - logger.error('Subscription upgrade exception:', error) - setError(error.message || `There was an unexpected error upgrading to the ${plan} plan`) - } finally { - setIsUpgrading(false) - } - } - const handleCancel = async () => { - if (!session?.user) { - setError('You need to be logged in to cancel your subscription') - return - } - - setIsCanceling(true) - setError(null) - - try { - const result = await subscription.cancel({ - returnUrl: window.location.href, - }) - - if ('error' in result && result.error) { - setError(result.error.message || 'There was an error canceling your subscription') - logger.error('Subscription cancellation error:', result.error) + try { + setIsUpdatingSeats(true) + await addSeats(seats) + setIsSeatsDialogOpen(false) + } catch (error) { + logger.error('Failed to update seats:', error) + } finally { + setIsUpdatingSeats(false) } - } catch (error: any) { - logger.error('Subscription cancellation exception:', error) - setError(error.message || 'There was an unexpected error canceling your subscription') - } finally { - setIsCanceling(false) - } + }, + [activeOrgId] + ) + + if (isLoading) { + return ( +
+ + + +
+ ) } - const handleTeamUpgrade = () => { - setIsTeamDialogOpen(true) - } - - const confirmTeamUpgrade = async (selectedSeats?: number) => { - if (!session?.user) { - setError('You need to be logged in to upgrade your team subscription') - return - } - - setIsUpgradingTeam(true) - setError(null) - - const seatsToUse = selectedSeats || seats - - try { - const result = await subscription.upgrade({ - plan: 'team', - seats: seatsToUse, - successUrl: window.location.href, - cancelUrl: window.location.href, - }) - - if ('error' in result && result.error) { - setError(result.error.message || 'There was an error upgrading to the team plan') - logger.error('Team subscription upgrade error:', result.error) - } else { - // Close the dialog after successful upgrade - setIsTeamDialogOpen(false) - } - } catch (error: any) { - logger.error('Team subscription upgrade exception:', error) - setError(error.message || 'There was an unexpected error upgrading to the team plan') - } finally { - setIsUpgradingTeam(false) - } - } - - return ( -
-

Subscription Plans

- - {error && ( - + if (error) { + return ( +
+ + Error {error} - )} +
+ ) + } - {(usageData.isWarning || usageData.isExceeded) && !isPro && ( - - - {usageData.isExceeded ? 'Usage Limit Exceeded' : 'Usage Warning'} - - You've used {usageData.percentUsed}% of your free tier limit ( - {usageData.currentUsage.toFixed(2)}$ of {usageData.limit}$). - {usageData.isExceeded - ? ' You have exceeded your limit. Upgrade to Pro to continue using all features.' - : ' Upgrade to Pro to avoid any service interruptions.'} - - - )} - - {loading ? ( - - ) : ( - <> -
- {/* Free Tier */} -
- {!isPro && ( -
- Current Plan -
- )} -
-

Free Tier

-

For individual users

- -
-
- $0 - /month -
-
- ${!isPro ? 5 : usageData.limit} inference credits included -
-
- -
    -
  • - โ€ข - Basic features -
  • -
  • - โ€ข - No sharing capabilities -
  • -
  • - โ€ข - 7 day log retention -
  • -
- - {!isPro && ( -
-
- Usage - - {usageData.currentUsage.toFixed(2)}$ / {usageData.limit}$ - -
- *]:bg-destructive' - : usageData.isWarning - ? 'bg-muted [&>*]:bg-amber-500' - : '[&>*]:bg-primary' - }`} - /> -
- )} - -
- {!isPro ? ( -
- Current Plan -
- ) : ( - - )} -
-
-
- - {/* Pro Tier */} -
- {isPro && !isTeam && !isEnterprise && ( -
- Current Plan -
- )} -
-

Pro Tier

-

- For professional users and teams -

- -
-
- $20 - /month -
-
- ${isPro && !isTeam && !isEnterprise ? usageData.limit : 20} inference credits - included -
-
- -
    -
  • - โ€ข - All features included -
  • -
  • - โ€ข - Workflow sharing capabilities -
  • -
  • - โ€ข - Extended log retention -
  • -
- - {isPro && !isTeam && !isEnterprise && ( -
-
- Usage - - {usageData.currentUsage.toFixed(2)}$ / {usageData.limit}$ - -
- *]:bg-destructive' - : usageData.isWarning - ? 'bg-muted [&>*]:bg-amber-500' - : '[&>*]:bg-primary' - }`} - /> -
- )} - -
- {isPro && !isTeam && !isEnterprise ? ( -
- Current Plan -
- ) : ( - - )} -
-
-
- - {/* Team Tier */} -
- {isTeam && ( -
- Current Plan -
- )} -
-

Team Tier

-

For collaborative teams

- -
-
- $40 - /seat/month -
-
- $40 inference credits per seat -
-
- -
    -
  • - โ€ข - All Pro features included -
  • -
  • - โ€ข - Real-time multiplayer collaboration -
  • -
  • - โ€ข - Shared workspace for team members -
  • -
  • - โ€ข - Unlimited log retention -
  • -
- - {isTeam && ( -
-
- Usage - - {usageData.currentUsage.toFixed(2)}$ / {(subscriptionData?.seats || 1) * 40} - $ - -
- *]:bg-destructive' - : usageData.isWarning - ? 'bg-muted [&>*]:bg-amber-500' - : '[&>*]:bg-primary' - }`} - /> - -
- Team Size - - {subscriptionData?.seats || 1}{' '} - {subscriptionData?.seats === 1 ? 'seat' : 'seats'} - -
-
- )} - -
- {isTeam ? ( -
- Current Plan -
- ) : ( - - )} -
-
-
- - {/* Enterprise Tier */} -
- {isEnterprise && ( -
- Current Plan -
- )} -
-
-
-

Enterprise

-

- For larger teams and organizations -

- -
-
Custom
-
- Contact us for custom pricing -
-
-
- -
-
    -
  • - โ€ข - Custom cost limits -
  • -
  • - โ€ข - Priority support -
  • -
  • - โ€ข - Custom integrations -
  • -
  • - โ€ข - Dedicated account manager -
  • -
  • - โ€ข - Unlimited log retention -
  • -
  • - โ€ข - 24/7 slack support -
  • - {isEnterprise && subscriptionData?.metadata?.perSeatAllowance && ( -
  • - โ€ข - - ${subscriptionData.metadata.perSeatAllowance} inference credits per seat - -
  • - )} -
-
-
- - {isEnterprise && ( -
-
- Usage - - {usageData.currentUsage.toFixed(2)}$ / {usageData.limit}$ - -
- *]:bg-destructive' - : usageData.isWarning - ? 'bg-muted [&>*]:bg-amber-500' - : '[&>*]:bg-primary' - }`} - /> - -
- Team Size - - {subscriptionData?.seats || 1}{' '} - {subscriptionData?.seats === 1 ? 'seat' : 'seats'} - -
- - {subscriptionData?.metadata?.totalAllowance && ( -
- Total Allowance - ${subscriptionData.metadata.totalAllowance} -
- )} -
- )} - -
- {isEnterprise ? ( -
- Current Plan -
- ) : ( - - )} -
-
-
-
- - {subscriptionData && ( -
-

Subscription Details

-
-

- Status:{' '} - {subscriptionData.status} -

- {subscriptionData.periodEnd && ( -

- Next billing date:{' '} - {new Date(subscriptionData.periodEnd).toLocaleDateString()} -

- )} - {isPro && ( -
- -
- )} -
-
- )} - - { - setSeats(selectedSeats) - await confirmTeamUpgrade(selectedSeats) - }} - confirmButtonText='Upgrade to Team Plan' - /> - - )} -
- ) -} - -// Skeleton component for subscription loading state -function SubscriptionSkeleton() { return ( -
-
- {/* Free Tier Skeleton */} -
- - - -
- - - +
+
+ {/* Current Plan & Usage Overview */} +
+
+

Current Plan

+
+ + {subscription.plan} Plan + + {!subscription.isFree && } +
-
- +
+ + ${usage.current.toFixed(2)} / ${usage.limit} + +
+ + {usage.percentUsed}% used this period + +
- {/* Pro Tier Skeleton */} -
- - + {/* Usage Alerts */} + {billingStatus === 'exceeded' && ( + + + Usage Limit Exceeded + + You've exceeded your usage limit of ${usage.limit}. Please upgrade your plan or + increase your limit. + + + )} -
- - - -
+ {billingStatus === 'warning' && ( + + + Approaching Usage Limit + + You've used {usage.percentUsed}% of your ${usage.limit} limit. Consider upgrading or + increasing your limit. + + + )} -
- + {/* Usage Limit Editor */} +
+
+ + {subscription.isTeam ? 'Individual Limit' : 'Monthly Limit'} + + {isLoadingOrgBilling ? ( + + ) : ( + + )}
+ {subscription.isFree && ( +

+ Upgrade to Pro ($20 minimum) or Team ($40 minimum) to customize your usage limit. +

+ )} + {subscription.isPro && ( +

+ Pro plan minimum: $20. You can set your individual limit higher. +

+ )} + {subscription.isTeam && !isTeamAdmin && ( +

+ Contact your team owner to adjust your limit. Team plan minimum: $40. +

+ )} + {subscription.isTeam && isTeamAdmin && ( +

+ Team plan minimum: $40 per member. Manage team member limits in the Team tab. +

+ )}
- {/* Team Tier Skeleton */} -
- - + {/* Team Management */} + {subscription.isTeam && ( +
+ {isLoadingOrgBilling ? ( + + +
+
+ + +
+ +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ ) : shouldShowOrgBilling ? ( + + +
+ + + Team Plan + +
+
+ + {/* Team Summary */} +
+
+ Licensed Seats + + {organizationBillingData.totalSeats} seats + +
+
+ Monthly Bill + + ${organizationBillingData.totalSeats * 40} + +
+
+ Current Usage + + ${organizationBillingData.totalCurrentUsage.toFixed(2)} + +
+
-
- - - - + {/* Simple Explanation */} +
+

+ You pay ${organizationBillingData.totalSeats * 40}/month for{' '} + {organizationBillingData.totalSeats} licensed seats, regardless of usage. If + your team uses more than ${organizationBillingData.totalSeats * 40}, you'll be + charged for the overage. +

+
+ + + ) : ( + + + + + Team Plan + + + +
+
+ Your monthly allowance + ${usage.limit} +
+

+ Contact your team owner to adjust your limit +

+
+
+
+ )}
+ )} -
- + {/* Upgrade Actions */} + {subscription.isFree && ( +
+ + +
+

+ Need a custom plan?{' '} + + Contact us + {' '} + for Enterprise pricing +

+
-
+ )} - {/* Enterprise Tier Skeleton */} -
- - + {subscription.isPro && !subscription.isTeam && ( + + )} -
- - - - + {subscription.isEnterprise && ( +
+

+ Enterprise plan - Contact support for changes +

+ )} -
- -
-
+ {/* Cancel Subscription */} + + + {/* Team Seats Dialog */} +
) } - -// Skeleton component for loading state in buttons -function ButtonSkeleton() { - return -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card.tsx new file mode 100644 index 000000000..63b007e28 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card.tsx @@ -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( + ({ 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 ( +
+ {permissionOptions.map((option, index) => ( + + ))} +
+ ) + } +) + +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 + onLoadUserWorkspaces: () => Promise + onWorkspaceToggle: (workspaceId: string, permission: string) => void + inviteSuccess: boolean +} + +function ButtonSkeleton() { + return ( +
+ ) +} + +export function MemberInvitationCard({ + inviteEmail, + setInviteEmail, + isInviting, + showWorkspaceInvite, + setShowWorkspaceInvite, + selectedWorkspaces, + userWorkspaces, + onInviteMember, + onLoadUserWorkspaces, + onWorkspaceToggle, + inviteSuccess, +}: MemberInvitationCardProps) { + const selectedCount = selectedWorkspaces.length + + return ( + + + Invite Team Members + + Add new members to your team and optionally give them access to specific workspaces + + + +
+
+ setInviteEmail(e.target.value)} + disabled={isInviting} + className='w-full' + /> +
+ + +
+ + {showWorkspaceInvite && ( +
+
+
+
Workspace Access
+ + Optional + +
+ {selectedCount > 0 && ( + {selectedCount} selected + )} +
+

+ Grant access to specific workspaces. You can modify permissions later. +

+ + {userWorkspaces.length === 0 ? ( +
+

No workspaces available

+

+ You need admin access to workspaces to invite members +

+
+ ) : ( +
+ {userWorkspaces.map((workspace) => { + const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id) + const selectedWorkspace = selectedWorkspaces.find( + (w) => w.workspaceId === workspace.id + ) + + return ( +
+
+ { + if (checked) { + onWorkspaceToggle(workspace.id, 'read') + } else { + onWorkspaceToggle(workspace.id, '') + } + }} + disabled={isInviting} + /> + + {workspace.isOwner && ( + + Owner + + )} +
+ + {isSelected && ( +
+ onWorkspaceToggle(workspace.id, permission)} + disabled={isInviting} + className='h-8' + /> +
+ )} +
+ ) + })} +
+ )} +
+ )} + + {inviteSuccess && ( + + + + Invitation sent successfully + {selectedCount > 0 && + ` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`} + + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view.tsx new file mode 100644 index 000000000..672849fbf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view.tsx @@ -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) => void + onCreateOrganization: () => Promise + 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 ( +
+
+

Create Your Team Workspace

+ +
+

+ You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your + workspace to start collaborating with your team. +

+ +
+
+ + +
+ +
+ +
+
+ simstudio.ai/team/ +
+ setOrgSlug(e.target.value)} + className='rounded-l-none' + /> +
+
+
+ + {error && ( + + Error + {error} + + )} + +
+ +
+
+
+ + +
+ ) + } + + return ( +
+
+

No Team Workspace

+

+ You don't have a team workspace yet. To collaborate with others, first upgrade to a team + or enterprise plan. +

+ + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog.tsx new file mode 100644 index 000000000..547eec3ff --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog.tsx @@ -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) => void + orgSlug: string + onOrgSlugChange: (slug: string) => void + onCreateOrganization: () => Promise + isCreating: boolean + error: string | null +} + +export function OrganizationCreationDialog({ + open, + onOpenChange, + orgName, + onOrgNameChange, + orgSlug, + onOrgSlugChange, + onCreateOrganization, + isCreating, + error, +}: OrganizationCreationDialogProps) { + return ( + + + + Create Team Workspace + + Create a workspace for your team to collaborate on projects. + + + +
+
+ + +
+ +
+ +
+
+ simstudio.ai/team/ +
+ onOrgSlugChange(e.target.value)} + className='rounded-l-none' + /> +
+
+
+ + {error && ( + + Error + {error} + + )} + + + + + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-settings-tab.tsx new file mode 100644 index 000000000..d1f519d41 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-settings-tab.tsx @@ -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 + isSavingOrgSettings: boolean + orgSettingsError: string | null + orgSettingsSuccess: string | null +} + +export function OrganizationSettingsTab({ + organization, + isAdminOrOwner, + userRole, + orgFormData, + onOrgInputChange, + onSaveOrgSettings, + isSavingOrgSettings, + orgSettingsError, + orgSettingsSuccess, +}: OrganizationSettingsTabProps) { + return ( +
+ {orgSettingsError && ( + + Error + {orgSettingsError} + + )} + + {orgSettingsSuccess && ( + + Success + {orgSettingsSuccess} + + )} + + {!isAdminOrOwner && ( + + Read Only + + You need owner or admin permissions to modify team settings. + + + )} + + + + Basic Information + Update your team's basic information and branding + + +
+ + onOrgInputChange('name', e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && isAdminOrOwner && !isSavingOrgSettings) { + onSaveOrgSettings() + } + }} + placeholder='Enter team name' + disabled={!isAdminOrOwner || isSavingOrgSettings} + /> +
+ +
+ + onOrgInputChange('slug', e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && isAdminOrOwner && !isSavingOrgSettings) { + onSaveOrgSettings() + } + }} + placeholder='team-slug' + disabled={!isAdminOrOwner || isSavingOrgSettings} + /> +

+ Used in URLs and API references. Can only contain lowercase letters, numbers, hyphens, + and underscores. +

+
+ +
+ + onOrgInputChange('logo', e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && isAdminOrOwner && !isSavingOrgSettings) { + onSaveOrgSettings() + } + }} + placeholder='https://example.com/logo.png' + disabled={!isAdminOrOwner || isSavingOrgSettings} + /> +
+
+
+ + + + Team Information + + +
+ Team ID: + {organization.id} +
+
+ Created: + {new Date(organization.createdAt).toLocaleDateString()} +
+
+ Your Role: + {userRole} +
+
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/pending-invitations-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/pending-invitations-list.tsx new file mode 100644 index 000000000..2ce65353e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/pending-invitations-list.tsx @@ -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 ( +
+

Pending Invitations

+
+ {pendingInvitations.map((invitation: Invitation) => ( +
+
+
+
+ {invitation.email.charAt(0).toUpperCase()} +
+
+
{invitation.email}
+
Invitation pending
+
+
+
+ + +
+ ))} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/remove-member-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/remove-member-dialog.tsx new file mode 100644 index 000000000..b139ef710 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/remove-member-dialog.tsx @@ -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 + onCancel: () => void +} + +export function RemoveMemberDialog({ + open, + memberName, + shouldReduceSeats, + onOpenChange, + onShouldReduceSeatsChange, + onConfirmRemove, + onCancel, +}: RemoveMemberDialogProps) { + return ( + + + + Remove Team Member + + Are you sure you want to remove {memberName} from the team? + + + +
+
+ onShouldReduceSeatsChange(e.target.checked)} + /> + +
+

+ If selected, your team seat count will be reduced by 1, lowering your monthly billing. +

+
+ + + + + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list.tsx new file mode 100644 index 000000000..5082b570c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list.tsx @@ -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 ( +
+

Team Members

+
+ No members in this organization yet. +
+
+ ) + } + + return ( +
+

Team Members

+
+ {organization.members.map((member: Member) => ( +
+
+
+
+ {(member.user?.name || member.user?.email || 'U').charAt(0).toUpperCase()} +
+
+
{member.user?.name || 'Unknown'}
+
{member.user?.email}
+
+
+ {member.role.charAt(0).toUpperCase() + member.role.slice(1)} +
+
+
+ + {/* Only show remove button for non-owners and if current user is admin/owner */} + {isAdminOrOwner && + member.role !== 'owner' && + member.user?.email !== currentUserEmail && ( + + )} +
+ ))} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview.tsx new file mode 100644 index 000000000..13713dd20 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview.tsx @@ -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 + onReduceSeats: () => Promise + onAddSeatDialog: () => void +} + +function TeamSeatsSkeleton() { + return ( +
+ + +
+ ) +} + +export function TeamSeatsOverview({ + subscriptionData, + isLoadingSubscription, + usedSeats, + isLoading, + onConfirmTeamUpgrade, + onReduceSeats, + onAddSeatDialog, +}: TeamSeatsOverviewProps) { + if (isLoadingSubscription) { + return ( + + + Team Seats Overview + Manage your team's seat allocation and billing + + + + + + ) + } + + if (!subscriptionData) { + return ( + + + Team Seats Overview + Manage your team's seat allocation and billing + + +
+
+ +
+
+

No Team Subscription Found

+

+ Your subscription may need to be transferred to this organization. +

+
+ +
+
+
+ ) + } + + return ( + + + Team Seats Overview + Manage your team's seat allocation and billing + + +
+
+
+

{subscriptionData.seats || 0}

+

Licensed Seats

+
+
+

{usedSeats}

+

Used Seats

+
+
+

{(subscriptionData.seats || 0) - usedSeats}

+

Available

+
+
+ +
+
+ Seat Usage + + {usedSeats} of {subscriptionData.seats || 0} seats + +
+ +
+ +
+ Seat Cost: + + ${((subscriptionData.seats || 0) * 40).toFixed(2)} + +
+
+ Individual usage limits may vary. See Subscription tab for team totals. +
+ + {checkEnterprisePlan(subscriptionData) ? ( +
+

Enterprise Plan

+

Contact support to modify seats

+
+ ) : ( +
+ + +
+ )} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx index ea6a05d4e..f143294f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/team-management.tsx @@ -1,100 +1,68 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { CheckCircle, Copy, PlusCircle, RefreshCw, UserX, XCircle } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from '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' -import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { client, useSession } from '@/lib/auth-client' +import { useSession } from '@/lib/auth-client' +import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' -import { checkEnterprisePlan } from '@/lib/subscription/utils' +import { generateSlug, useOrganizationStore } from '@/stores/organization' +import { useSubscriptionStore } from '@/stores/subscription/store' import { TeamSeatsDialog } from '../subscription/components/team-seats-dialog' +import { TeamUsageOverview } from '../subscription/components/team-usage-overview' +import { MemberInvitationCard } from './components/member-invitation-card' +import { NoOrganizationView } from './components/no-organization-view' +import { OrganizationSettingsTab } from './components/organization-settings-tab' +import { PendingInvitationsList } from './components/pending-invitations-list' +import { RemoveMemberDialog } from './components/remove-member-dialog' +import { TeamMembersList } from './components/team-members-list' +import { TeamSeatsOverview } from './components/team-seats-overview' const logger = createLogger('TeamManagement') -type User = { name?: string; email?: string } - -type Member = { - id: string - role: string - user?: User -} - -type Invitation = { - id: string - email: string - status: string -} - -type Organization = { - id: string - name: string - slug: string - members?: Member[] - invitations?: Invitation[] - createdAt: string | Date - [key: string]: unknown -} - -interface SubscriptionMetadata { - perSeatAllowance?: number - totalAllowance?: number - [key: string]: unknown -} - -type Subscription = { - id: string - plan: string - status: string - seats?: number - referenceId: string - cancelAtPeriodEnd?: boolean - periodEnd?: number | Date - trialEnd?: number | Date - metadata?: SubscriptionMetadata - [key: string]: unknown -} - -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 } -} - -function useOrganizationRole(userEmail: string | undefined, org: Organization | null | undefined) { - return useMemo(() => { - if (!userEmail || !org?.members) { - return { userRole: 'member', isAdminOrOwner: false } - } - const currentMember = org.members.find((m) => m.user?.email === userEmail) - const role = currentMember?.role ?? 'member' - return { - userRole: role, - isAdminOrOwner: role === 'owner' || role === 'admin', - } - }, [userEmail, org]) -} - export function TeamManagement() { const { data: session } = useSession() - const { data: activeOrg } = client.useActiveOrganization() - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - const [organizations, setOrganizations] = useState([]) + const { + organizations, + activeOrganization, + subscriptionData, + userWorkspaces, + orgFormData, + hasTeamPlan, + hasEnterprisePlan, + isLoading, + isLoadingSubscription, + isCreatingOrg, + isInviting, + isSavingOrgSettings, + error, + orgSettingsError, + inviteSuccess, + orgSettingsSuccess, + loadData, + createOrganization, + setActiveOrganization, + inviteMember, + removeMember, + cancelInvitation, + addSeats, + reduceSeats, + updateOrganizationSettings, + loadUserWorkspaces, + getUserRole, + isAdminOrOwner, + getUsedSeats, + setOrgFormData, + } = useOrganizationStore() + + const { getSubscriptionStatus } = useSubscriptionStore() + const [inviteEmail, setInviteEmail] = useState('') - const [isInviting, setIsInviting] = useState(false) - const [isCreatingOrg, setIsCreatingOrg] = useState(false) + const [showWorkspaceInvite, setShowWorkspaceInvite] = useState(false) + const [selectedWorkspaces, setSelectedWorkspaces] = useState< + Array<{ workspaceId: string; permission: string }> + >([]) const [createOrgDialogOpen, setCreateOrgDialogOpen] = useState(false) const [removeMemberDialog, setRemoveMemberDialog] = useState<{ open: boolean @@ -104,780 +72,190 @@ export function TeamManagement() { }>({ open: false, memberId: '', memberName: '', shouldReduceSeats: false }) const [orgName, setOrgName] = useState('') const [orgSlug, setOrgSlug] = useState('') - const [inviteSuccess, setInviteSuccess] = useState(false) const [activeTab, setActiveTab] = useState('members') - const [activeOrganization, setActiveOrganization] = useState(null) - const [subscriptionData, setSubscriptionData] = useState(null) - const [isLoadingSubscription, setIsLoadingSubscription] = useState(false) - const [hasTeamPlan, setHasTeamPlan] = useState(false) - const [hasEnterprisePlan, setHasEnterprisePlan] = useState(false) - const { userRole, isAdminOrOwner } = useOrganizationRole(session?.user?.email, activeOrganization) - const { used: usedSeats } = useMemo( - () => calculateSeatUsage(activeOrganization), - [activeOrganization] - ) - const [isAddSeatDialogOpen, setIsAddSeatDialogOpen] = useState(false) const [newSeatCount, setNewSeatCount] = useState(1) const [isUpdatingSeats, setIsUpdatingSeats] = useState(false) - const loadData = useCallback(async () => { - if (!session?.user) return + const userRole = getUserRole(session?.user?.email) + const adminOrOwner = isAdminOrOwner(session?.user?.email) + const usedSeats = getUsedSeats() + const subscription = getSubscriptionStatus() - try { - setIsLoading(true) - setError(null) - - // Get all organizations the user is a member of - const orgsResponse = await client.organization.list() - setOrganizations(orgsResponse.data || []) - - // Check if user has a team or enterprise subscription - const response = await fetch('/api/user/subscription') - const data = await response.json() - setHasTeamPlan(data.isTeam) - setHasEnterprisePlan(data.isEnterprise) - - // Set default organization name and slug for organization creation - // but no longer automatically showing the dialog - if (data.isTeam || data.isEnterprise) { - setOrgName(`${session.user.name || 'My'}'s Team`) - setOrgSlug(generateSlug(`${session.user.name || 'My'}'s Team`)) - } - } catch (err: any) { - setError(err.message || 'Failed to load data') - logger.error('Failed to load data:', err) - } finally { - setIsLoading(false) - } - }, [session?.user]) - - // Update local state when the active organization changes + const hasLoadedInitialData = useRef(false) useEffect(() => { - if (activeOrg) { - setActiveOrganization(activeOrg) - - // Load subscription data for the organization - if (activeOrg.id) { - loadOrganizationSubscription(activeOrg.id) - } + if (!hasLoadedInitialData.current) { + loadData() + hasLoadedInitialData.current = true } - }, [activeOrg]) + }, []) - // Load organization's subscription data - const loadOrganizationSubscription = async (orgId: string) => { - try { - setIsLoadingSubscription(true) - 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 }) - setError('Failed to load subscription data') - } else { - logger.info('Organization subscription data loaded', { - subscriptions: data?.map((s) => ({ - id: s.id, - plan: s.plan, - status: s.status, - seats: s.seats, - referenceId: s.referenceId, - })), - }) - - // Find active team or enterprise subscription - const teamSubscription = data?.find((sub) => sub.status === 'active' && sub.plan === 'team') - const enterpriseSubscription = data?.find((sub) => checkEnterprisePlan(sub)) - - // Use enterprise plan if available, otherwise team plan - const activeSubscription = enterpriseSubscription || teamSubscription - - if (activeSubscription) { - logger.info('Found active subscription', { - id: activeSubscription.id, - plan: activeSubscription.plan, - seats: activeSubscription.seats, - }) - setSubscriptionData(activeSubscription) - } else { - // If no subscription found through client API, check for enterprise subscriptions - if (hasEnterprisePlan) { - try { - const enterpriseResponse = await fetch('/api/user/subscription/enterprise') - if (enterpriseResponse.ok) { - const enterpriseData = await enterpriseResponse.json() - if (enterpriseData.subscription) { - logger.info('Found enterprise subscription', { - id: enterpriseData.subscription.id, - seats: enterpriseData.subscription.seats, - }) - setSubscriptionData(enterpriseData.subscription) - return - } - } - } catch (err) { - logger.error('Error fetching enterprise subscription', { - error: err, - }) - } - } - - logger.warn('No active subscription found for organization', { - orgId, - }) - setSubscriptionData(null) - } - } - } catch (err: any) { - logger.error('Error loading subscription data', { error: err }) - setError(err.message || 'Failed to load subscription data') - } finally { - setIsLoadingSubscription(false) - } - } - - // Initial data loading + // Set default organization name for team/enterprise users useEffect(() => { - loadData() - }, [loadData]) - - // Refresh organization data - const refreshOrganization = useCallback(async () => { - if (!activeOrganization?.id) return - - try { - const fullOrgResponse = await client.organization.getFullOrganization() - setActiveOrganization(fullOrgResponse.data) - - // Also refresh subscription data when organization is refreshed - if (fullOrgResponse.data?.id) { - await loadOrganizationSubscription(fullOrgResponse.data.id) - } - } catch (err: any) { - setError(err.message || 'Failed to refresh organization data') + if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) { + const defaultName = `${session.user.name}'s Team` + setOrgName(defaultName) + setOrgSlug(generateSlug(defaultName)) } - }, [activeOrganization?.id]) + }, [hasTeamPlan, hasEnterprisePlan, session?.user?.name, orgName]) - // Handle seat reduction - remove members when seats are reduced - const handleReduceSeats = async () => { - if (!session?.user || !activeOrganization || !subscriptionData) return - - // Don't allow enterprise users to modify seats - if (checkEnterprisePlan(subscriptionData)) { - setError('Enterprise plan seats can only be modified by contacting support') - return + // Load workspaces for admin users + const activeOrgId = activeOrganization?.id + useEffect(() => { + if (session?.user?.id && activeOrgId && adminOrOwner) { + loadUserWorkspaces(session.user.id) } + }, [session?.user?.id, activeOrgId, adminOrOwner]) - const currentSeats = subscriptionData.seats || 0 - if (currentSeats <= 1) { - setError('Cannot reduce seats below 1') - return - } - - const { used: totalCount } = calculateSeatUsage(activeOrganization) - - if (totalCount >= currentSeats) { - setError( - `You have ${totalCount} active members/invitations. Please remove members or cancel invitations before reducing seats.` - ) - return - } - - try { - await reduceSeats(currentSeats - 1) - await refreshOrganization() - } catch (err: any) { - setError(err.message || 'Failed to reduce seats') - } - } - - const generateSlug = (name: string) => { - return name.toLowerCase().replace(/[^a-z0-9]/g, '-') - } - - const handleOrgNameChange = (e: React.ChangeEvent) => { + const handleOrgNameChange = useCallback((e: React.ChangeEvent) => { const newName = e.target.value setOrgName(newName) setOrgSlug(generateSlug(newName)) - } + }, []) - const handleCreateOrganization = async () => { - if (!session?.user) return + const handleCreateOrganization = useCallback(async () => { + if (!session?.user || !orgName.trim()) return + await createOrganization(orgName.trim(), orgSlug.trim()) + setCreateOrgDialogOpen(false) + setOrgName('') + setOrgSlug('') + }, [session?.user?.id, orgName, orgSlug]) - try { - setIsCreatingOrg(true) - setError(null) + const handleInviteMember = useCallback(async () => { + if (!session?.user || !activeOrgId || !inviteEmail.trim()) return - logger.info('Creating team organization', { - name: orgName, - slug: orgSlug, - }) + await inviteMember( + inviteEmail.trim(), + selectedWorkspaces.length > 0 ? selectedWorkspaces : undefined + ) - // Create the organization using Better Auth API - const result = await client.organization.create({ - name: orgName, - slug: orgSlug, - }) + setInviteEmail('') + setSelectedWorkspaces([]) + setShowWorkspaceInvite(false) + }, [session?.user?.id, activeOrgId, inviteEmail, selectedWorkspaces]) - if (!result.data?.id) { - throw new Error('Failed to create organization') + const handleWorkspaceToggle = useCallback((workspaceId: string, permission: string) => { + setSelectedWorkspaces((prev) => { + const exists = prev.find((w) => w.workspaceId === workspaceId) + + if (!permission || permission === '') { + return prev.filter((w) => w.workspaceId !== workspaceId) } - const orgId = result.data.id - logger.info('Organization created', { orgId }) - - // Set the new organization as active - logger.info('Setting organization as active', { orgId }) - await client.organization.setActive({ - organizationId: orgId, - }) - - // If the user has a team or enterprise subscription, update the subscription reference - // directly through a custom API endpoint instead of using upgrade - if (hasTeamPlan || hasEnterprisePlan) { - const userSubResponse = await client.subscription.list() - - let teamSubscription = userSubResponse.data?.find( - (sub) => (sub.plan === 'team' || sub.plan === 'enterprise') && sub.status === 'active' - ) - - // If no subscription was found through the client API but user has enterprise plan, - // fetch it directly through our enterprise subscription endpoint - if (!teamSubscription && hasEnterprisePlan) { - logger.info('No subscription found via client API, checking enterprise endpoint') - try { - const enterpriseResponse = await fetch('/api/user/subscription/enterprise') - if (enterpriseResponse.ok) { - const enterpriseData = await enterpriseResponse.json() - if (enterpriseData.subscription) { - teamSubscription = enterpriseData.subscription - logger.info('Found enterprise subscription via direct API', { - subscriptionId: teamSubscription?.id, - plan: teamSubscription?.plan, - seats: teamSubscription?.seats, - }) - } - } - } catch (err) { - logger.error('Error fetching enterprise subscription details', { - error: err, - }) - } - } - - logger.info('Team subscription to transfer', { - found: !!teamSubscription, - details: teamSubscription - ? { - id: teamSubscription.id, - plan: teamSubscription.plan, - status: teamSubscription.status, - } - : null, - }) - - if (teamSubscription) { - logger.info('Found subscription to transfer', { - subscriptionId: teamSubscription.id, - plan: teamSubscription.plan, - seats: teamSubscription.seats, - targetOrgId: orgId, - }) - - // Use a custom API endpoint to transfer the subscription without going to Stripe - try { - const transferResponse = await fetch( - `/api/user/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) { - // Parsing failed, use the raw text - errorMessage = errorText || errorMessage - } - - throw new Error(errorMessage) - } - } catch (transferError) { - logger.error('Subscription transfer failed', { - error: transferError instanceof Error ? transferError.message : String(transferError), - }) - throw transferError - } - } + if (exists) { + return prev.map((w) => (w.workspaceId === workspaceId ? { ...w, permission } : w)) } - // Refresh the organization list - await loadData() - - // Close the dialog - setCreateOrgDialogOpen(false) - setOrgName('') - setOrgSlug('') - } catch (err: any) { - logger.error('Failed to create organization', { error: err }) - setError(err.message || 'Failed to create organization') - } finally { - setIsCreatingOrg(false) - } - } - - // Upgrade to team subscription with organization as reference - const confirmTeamUpgrade = async (seats: number) => { - if (!session?.user || !activeOrganization) return - - try { - setIsLoading(true) - setError(null) - - // Use the organization's ID as the reference for the team subscription - const { error } = await client.subscription.upgrade({ - plan: 'team', - referenceId: activeOrganization.id, - successUrl: window.location.href, - cancelUrl: window.location.href, - seats: seats, - }) - - if (error) { - setError(error.message || 'Failed to upgrade to team subscription') - } else { - await refreshOrganization() - } - } catch (err: any) { - setError(err.message || 'Failed to upgrade to team subscription') - } finally { - setIsLoading(false) - } - } - - // Set an organization as active - const handleSetActiveOrg = async (orgId: string) => { - if (!session?.user) return - - try { - setIsLoading(true) - - // Set the active organization - await client.organization.setActive({ - organizationId: orgId, - }) - } catch (err: any) { - setError(err.message || 'Failed to set active organization') - } finally { - setIsLoading(false) - } - } - - // Invite a member to the organization - const handleInviteMember = async () => { - if (!session?.user || !activeOrganization) return - - try { - setIsInviting(true) - setError(null) - setInviteSuccess(false) - - const { - used: totalCount, - pending: pendingInvitationCount, - members: currentMemberCount, - } = calculateSeatUsage(activeOrganization) - - const seatLimit = subscriptionData?.seats || 0 - - logger.info('Checking seat availability for invitation', { - currentMembers: currentMemberCount, - pendingInvites: pendingInvitationCount, - totalUsed: totalCount, - seatLimit, - subscriptionId: subscriptionData?.id, - }) - - if (totalCount >= seatLimit) { - setError( - `You've reached your team seat limit of ${seatLimit}. Please upgrade your plan for more seats.` - ) - return - } - - if (!inviteEmail || !inviteEmail.includes('@')) { - setError('Please enter a valid email address') - return - } - - logger.info('Sending invitation to member', { - email: inviteEmail, - organizationId: activeOrganization.id, - }) - - // Invite the member - const inviteResult = await client.organization.inviteMember({ - email: inviteEmail, - role: 'member', - organizationId: activeOrganization.id, - }) - - if (inviteResult.error) { - throw new Error(inviteResult.error.message || 'Failed to send invitation') - } - - logger.info('Invitation sent successfully') - - // Clear the input and show success message - setInviteEmail('') - setInviteSuccess(true) - - // Refresh the organization - await refreshOrganization() - } catch (err: any) { - logger.error('Error inviting member', { error: err }) - setError(err.message || 'Failed to invite member') - } finally { - setIsInviting(false) - } - } - - // Remove a member from the organization - const handleRemoveMember = async (member: any) => { - if (!session?.user || !activeOrganization) return - - // Open confirmation dialog - setRemoveMemberDialog({ - open: true, - memberId: member.id, - memberName: member.user?.name || member.user?.email || 'this member', - shouldReduceSeats: false, + return [...prev, { workspaceId, permission }] }) - } + }, []) - // Actual member removal after confirmation - const confirmRemoveMember = async (shouldReduceSeats = false) => { - const { memberId } = removeMemberDialog - if (!session?.user || !activeOrganization || !memberId) return + const handleRemoveMember = useCallback( + async (member: any) => { + if (!session?.user || !activeOrgId) return - try { - setIsLoading(true) - - // Remove the member - 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 reduceSeats(currentSeats - 1) - } - } - - // Refresh the organization - await refreshOrganization() - - // Close the dialog setRemoveMemberDialog({ - open: false, - memberId: '', - memberName: '', + open: true, + memberId: member.id, + memberName: member.user?.name || member.user?.email || 'this member', shouldReduceSeats: false, }) - } catch (err: any) { - setError(err.message || 'Failed to remove member') - } finally { - setIsLoading(false) + }, + [session?.user?.id, activeOrgId] + ) + + const confirmRemoveMember = useCallback( + async (shouldReduceSeats = false) => { + const { memberId } = removeMemberDialog + if (!session?.user || !activeOrgId || !memberId) return + + await removeMember(memberId, shouldReduceSeats) + setRemoveMemberDialog({ open: false, memberId: '', memberName: '', shouldReduceSeats: false }) + }, + [removeMemberDialog.memberId, session?.user?.id, activeOrgId] + ) + + const handleReduceSeats = useCallback(async () => { + if (!session?.user || !activeOrgId || !subscriptionData) return + if (checkEnterprisePlan(subscriptionData)) return + + const currentSeats = subscriptionData.seats || 0 + if (currentSeats <= 1) return + + const { used: totalCount } = usedSeats + if (totalCount >= currentSeats) return + + await reduceSeats(currentSeats - 1) + }, [session?.user?.id, activeOrgId, subscriptionData?.seats, usedSeats.used]) + + const handleAddSeatDialog = useCallback(() => { + if (subscriptionData) { + setNewSeatCount((subscriptionData.seats || 1) + 1) + setIsAddSeatDialogOpen(true) } - } + }, [subscriptionData?.seats]) - // Cancel an invitation - const handleCancelInvitation = async (invitationId: string) => { - if (!session?.user || !activeOrganization) return + const confirmAddSeats = useCallback( + async (selectedSeats?: number) => { + if (!subscriptionData || !activeOrgId) return - try { - setIsLoading(true) + const seatsToUse = selectedSeats || newSeatCount + setIsUpdatingSeats(true) - // Cancel the invitation - await client.organization.cancelInvitation({ - invitationId, - }) + try { + await addSeats(seatsToUse) + setIsAddSeatDialogOpen(false) + } finally { + setIsUpdatingSeats(false) + } + }, + [subscriptionData?.id, activeOrgId, newSeatCount] + ) - // Refresh the organization - await refreshOrganization() - } catch (err: any) { - setError(err.message || 'Failed to cancel invitation') - } finally { - setIsLoading(false) - } - } + const handleOrgInputChange = useCallback((field: string, value: string) => { + setOrgFormData({ [field]: value }) + }, []) - const getEffectivePlanName = () => { - if (!subscriptionData) return 'No Plan' + const handleSaveOrgSettings = useCallback(async () => { + if (!activeOrgId || !adminOrOwner) return + await updateOrganizationSettings() + }, [activeOrgId, adminOrOwner]) - if (checkEnterprisePlan(subscriptionData)) { - return 'Enterprise' - } - if (subscriptionData.plan === 'team') { - return 'Team' - } + const confirmTeamUpgrade = useCallback( + async (seats: number) => { + if (!session?.user || !activeOrgId) return + logger.info('Team upgrade requested', { seats, organizationId: activeOrgId }) + alert(`Team upgrade to ${seats} seats - integration needed`) + }, + [session?.user?.id, activeOrgId] + ) + + if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) { return ( - subscriptionData.plan?.charAt(0).toUpperCase() + subscriptionData.plan?.slice(1) || 'Unknown' +
+ + + +
) } - // Handle opening the add seat dialog - const handleAddSeatDialog = () => { - if (subscriptionData) { - setNewSeatCount((subscriptionData.seats || 1) + 1) // Default to current seats + 1 - setIsAddSeatDialogOpen(true) - } - } - - // Handle reducing seats - const reduceSeats = async (newSeatCount: number) => { - if (!subscriptionData || !activeOrganization) return - - try { - setIsLoading(true) - setError(null) - - 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') - } finally { - setIsLoading(false) - } - } - - // Confirm seat addition - const confirmAddSeats = async (selectedSeats?: number) => { - if (!subscriptionData || !activeOrganization) return - - const seatsToUse = selectedSeats || newSeatCount - - try { - setIsUpdatingSeats(true) - setError(null) - - const { error } = await client.subscription.upgrade({ - plan: 'team', - referenceId: activeOrganization.id, - subscriptionId: subscriptionData.id, - seats: seatsToUse, - successUrl: window.location.href, - cancelUrl: window.location.href, - }) - - if (error) { - setError(error.message || 'Failed to update seats') - } else { - // Close the dialog after successful upgrade - setIsAddSeatDialogOpen(false) - await refreshOrganization() - } - } catch (err: any) { - setError(err.message || 'Failed to update seats') - } finally { - setIsUpdatingSeats(false) - } - } - - if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) { - return - } - - const getInvitationStatus = (status: string) => { - switch (status) { - case 'pending': - return ( -
- - Pending -
- ) - case 'accepted': - return ( -
- - Accepted -
- ) - case 'canceled': - return ( -
- - Canceled -
- ) - default: - return status - } - } - - // No organization yet - show creation UI if (!activeOrganization) { return ( -
-
-

- {hasTeamPlan || hasEnterprisePlan ? 'Create Your Team Workspace' : 'No Team Workspace'} -

- - {hasTeamPlan || hasEnterprisePlan ? ( -
-

- You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your - workspace to start collaborating with your team. -

- -
-
- - -
- -
- -
-
- simstudio.ai/team/ -
- setOrgSlug(e.target.value)} - className='rounded-l-none' - /> -
-
-
- - {error && ( - - Error - {error} - - )} - -
- -
-
- ) : ( - <> -

- You don't have a team workspace yet. To collaborate with others, first upgrade to a - team or enterprise plan. -

- - - - )} -
- - - - - Create Team Workspace - - Create a workspace for your team to collaborate on projects. - - - -
-
- - -
- -
- -
-
- simstudio.ai/team/ -
- setOrgSlug(e.target.value)} - className='rounded-l-none' - /> -
-
-
- - {error && ( - - Error - {error} - - )} - - - - - -
-
-
+ ) } @@ -891,7 +269,7 @@ export function TeamManagement() { setInviteEmail(e.target.value)} - disabled={isInviting} - /> - -
- - {inviteSuccess && ( -

Invitation sent successfully

- )} -
+ {adminOrOwner && ( + loadUserWorkspaces(session?.user?.id)} + onWorkspaceToggle={handleWorkspaceToggle} + inviteSuccess={inviteSuccess} + /> )} - {/* Team Seats Usage - only show to admins/owners */} - {isAdminOrOwner && ( -
-

Team Seats

- - {isLoadingSubscription ? ( - - ) : subscriptionData ? ( - <> -
- Used - - {usedSeats}/{subscriptionData.seats || 0} - -
- - - {checkEnterprisePlan(subscriptionData) ? ( -
- ) : ( -
- - -
- )} - - ) : ( -
-

No active subscription found for this organization.

-

- This might happen if your subscription was created for your personal account but - hasn't been properly transferred to the organization. -

- -
- )} -
+ {adminOrOwner && ( + )} - {/* Team Members - show to all users */} -
-

Team Members

+ - {activeOrganization.members?.length === 0 ? ( -
- No members in this organization yet. -
- ) : ( -
- {activeOrganization.members?.map((member: any) => ( -
-
-
{member.user?.name || 'Unknown'}
-
{member.user?.email}
-
- {member.role.charAt(0).toUpperCase() + member.role.slice(1)} -
-
- - {/* Only show remove button for non-owners and if current user is admin/owner */} - {isAdminOrOwner && - member.role !== 'owner' && - member.user?.email !== session?.user?.email && ( - - )} -
- ))} -
- )} -
- - {/* Pending Invitations - only show to admins/owners */} - {isAdminOrOwner && (activeOrganization.invitations?.length ?? 0) > 0 && ( -
-

Pending Invitations

- -
- {activeOrganization.invitations?.map((invitation: any) => ( -
-
-
{invitation.email}
-
{getInvitationStatus(invitation.status)}
-
- - {invitation.status === 'pending' && ( - - )} -
- ))} -
-
+ {adminOrOwner && (activeOrganization.invitations?.length ?? 0) > 0 && ( + )} - -
-
-

Team Workspace Name

-
{activeOrganization.name}
-
+ + + -
-

URL Slug

-
- - {activeOrganization.slug} - - -
-
- -
-

Created On

-
- {new Date(activeOrganization.createdAt).toLocaleDateString()} -
-
- - {/* Only show subscription details to admins/owners */} - {isAdminOrOwner && ( -
-

Subscription Status

- {isLoadingSubscription ? ( - - ) : subscriptionData ? ( -
-
-
- - {getEffectivePlanName()} {subscriptionData.status} - {subscriptionData.cancelAtPeriodEnd ? ' (Cancels at period end)' : ''} - -
-
-
Team seats: {subscriptionData.seats}
- {checkEnterprisePlan(subscriptionData) && subscriptionData.metadata && ( -
- {subscriptionData.metadata.perSeatAllowance && ( -
- Per-seat allowance: ${subscriptionData.metadata.perSeatAllowance} -
- )} - {subscriptionData.metadata.totalAllowance && ( -
Total allowance: ${subscriptionData.metadata.totalAllowance}
- )} -
- )} - {subscriptionData.periodEnd && ( -
- Next billing date:{' '} - {new Date(subscriptionData.periodEnd).toLocaleDateString()} -
- )} - {subscriptionData.trialEnd && ( -
- Trial ends: {new Date(subscriptionData.trialEnd).toLocaleDateString()} -
- )} -
- This subscription is associated with this team workspace. -
-
-
- ) : ( -
No active subscription found
- )} -
- )} - - {!isAdminOrOwner && ( -
-

Your Role

-
- You are a {userRole} of this - workspace. - {userRole === 'member' && ( -

- Contact a workspace admin or owner for subscription changes or to invite new - members. -

- )} -
-
- )} -
+ + - {/* Member removal confirmation dialog */} - { if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false }) }} - > - - - Remove Team Member - - Are you sure you want to remove {removeMemberDialog.memberName} from the team? - - + onShouldReduceSeatsChange={(shouldReduce) => + setRemoveMemberDialog({ + ...removeMemberDialog, + shouldReduceSeats: shouldReduce, + }) + } + onConfirmRemove={confirmRemoveMember} + onCancel={() => + setRemoveMemberDialog({ + open: false, + memberId: '', + memberName: '', + shouldReduceSeats: false, + }) + } + /> -
-
- - setRemoveMemberDialog({ - ...removeMemberDialog, - shouldReduceSeats: e.target.checked, - }) - } - /> - -
-

- If selected, your team seat count will be reduced by 1, lowering your monthly billing. -

-
- - - - - -
-
- - {/* Add Seat Dialog - using shared component */} ) } - -function TeamManagementSkeleton() { - return ( -
-
- - -
- -
-
- -
- - -
-
- -
- -
-
- - -
- -
- - -
-
-
- -
- -
- {[1, 2, 3].map((i) => ( -
-
- - - -
- -
- ))} -
-
-
-
- ) -} - -function ButtonSkeleton() { - return -} - -function TeamSeatsSkeleton() { - return ( -
- - -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index 251a516c7..152e0d1ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -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('general') - const [isPro, setIsPro] = useState(false) - const [isTeam, setIsTeam] = useState(false) - const [isEnterprise, setIsEnterprise] = useState(false) - const [subscriptionData, setSubscriptionData] = useState(null) - const [usageData, setUsageData] = useState(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 ( @@ -162,8 +107,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
@@ -186,22 +130,12 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{isSubscriptionEnabled && (
- -
- )} - {showTeamManagement && ( -
- +
)} +
+ +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 0630c63f5..b9b608a89 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -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( useSidebarStore() const { data: sessionData, isPending } = useSession() - const [plan, setPlan] = useState('Free Plan') + + const { getSubscriptionStatus } = useSubscriptionStore() + const subscription = getSubscriptionStatus() + + const getPlanName = (subscription: ReturnType) => { + 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([]) @@ -273,16 +286,6 @@ export const WorkspaceHeader = React.memo( 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( 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) => { diff --git a/apps/sim/components/emails/batch-invitation-email.tsx b/apps/sim/components/emails/batch-invitation-email.tsx new file mode 100644 index 000000000..0fa5bbfe6 --- /dev/null +++ b/apps/sim/components/emails/batch-invitation-email.tsx @@ -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 ( + + + + You've been invited to join {organizationName} + {hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''} + + + +
+ SimStudio +
+ + You're invited to join {organizationName}! + + + {inviterName} has invited you to join{' '} + {organizationName} on SimStudio. + + + {/* Organization Invitation Details */} +
+ Team Access +
+ Team Role: {getRoleLabel(organizationRole)} + + {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."} + +
+
+ + {/* Workspace Invitations */} + {hasWorkspaces && ( +
+ + Workspace Access ({workspaceInvitations.length} workspace + {workspaceInvitations.length !== 1 ? 's' : ''}) + + You're also being invited to the following workspaces: + + {workspaceInvitations.map((ws, index) => ( +
+ {ws.workspaceName} + {getPermissionLabel(ws.permission)} +
+ ))} +
+ )} + +
+ +
+ + + By accepting this invitation, you'll join {organizationName} + {hasWorkspaces ? ` and gain access to ${workspaceInvitations.length} workspace(s)` : ''} + . + + +
+ + + If you have any questions, you can reach out to {inviterName} directly or contact our + support team. + + + + This invitation will expire in 7 days. If you didn't expect this invitation, you can + safely ignore this email. + +
+ + + ) +} + +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', +} diff --git a/apps/sim/components/emails/render-email.ts b/apps/sim/components/emails/render-email.ts index 983e0410b..e57f26a22 100644 --- a/apps/sim/components/emails/render-email.ts +++ b/apps/sim/components/emails/render-email.ts @@ -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 { - 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 { - 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' } diff --git a/apps/sim/components/emails/waitlist-approval-email.tsx b/apps/sim/components/emails/waitlist-approval-email.tsx deleted file mode 100644 index 295acc2d6..000000000 --- a/apps/sim/components/emails/waitlist-approval-email.tsx +++ /dev/null @@ -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 ( - - - - You've Been Approved to Join Sim Studio! - -
- - - Sim Studio - - -
- -
- - - - - -
- -
- Great news! - - 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. - - - Your email ({email}) has been approved. Click the button below to create your account - and start using Sim Studio today: - - - Create Your Account - - - 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. - - - Best regards, -
- The Sim Studio Team -
-
-
- - - - - ) -} - -export default WaitlistApprovalEmail diff --git a/apps/sim/components/emails/waitlist-confirmation-email.tsx b/apps/sim/components/emails/waitlist-confirmation-email.tsx deleted file mode 100644 index 445f11af7..000000000 --- a/apps/sim/components/emails/waitlist-confirmation-email.tsx +++ /dev/null @@ -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 ( - - - - Welcome to the Sim Studio Waitlist! - -
- - - Sim Studio - - -
- -
- - - - - -
- -
- Welcome to the Sim Studio Waitlist! - - 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. - - - Want to get access sooner? Tell us about your use case! Schedule a - 15-minute call with our team to discuss how you plan to use Sim Studio. - - - Schedule a Call - - - We're excited to help you build, test, and optimize your agentic workflows. - - - Best regards, -
- The Sim Studio Team -
-
-
- - - - - ) -} - -export default WaitlistConfirmationEmail diff --git a/apps/sim/hooks/use-subscription-state.ts b/apps/sim/hooks/use-subscription-state.ts new file mode 100644 index 000000000..0a4620829 --- /dev/null +++ b/apps/sim/hooks/use-subscription-state.ts @@ -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(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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, + } +} diff --git a/apps/sim/hooks/use-user-subscription.ts b/apps/sim/hooks/use-user-subscription.ts deleted file mode 100644 index 8773245f2..000000000 --- a/apps/sim/hooks/use-user-subscription.ts +++ /dev/null @@ -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({ - 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 -} diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index 1edf9db6b..daab44b12 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -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, diff --git a/apps/sim/lib/auth/internal.ts b/apps/sim/lib/auth/internal.ts index 1f70b1af4..06de16792 100644 --- a/apps/sim/lib/auth/internal.ts +++ b/apps/sim/lib/auth/internal.ts @@ -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 { 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 +} diff --git a/apps/sim/lib/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts similarity index 85% rename from apps/sim/lib/usage-monitor.ts rename to apps/sim/lib/billing/calculations/usage-monitor.ts index 550b55fc1..a534a65de 100644 --- a/apps/sim/lib/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -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 { // 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 { } } - // 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 { } } - // 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) diff --git a/apps/sim/lib/billing/core/billing-periods.test.ts b/apps/sim/lib/billing/core/billing-periods.test.ts new file mode 100644 index 000000000..99fdc4a72 --- /dev/null +++ b/apps/sim/lib/billing/core/billing-periods.test.ts @@ -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) + }) + }) +}) diff --git a/apps/sim/lib/billing/core/billing-periods.ts b/apps/sim/lib/billing/core/billing-periods.ts new file mode 100644 index 000000000..1cf47194b --- /dev/null +++ b/apps/sim/lib/billing/core/billing-periods.ts @@ -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 { + 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 { + 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 { + 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 + } +} diff --git a/apps/sim/lib/billing/core/billing.test.ts b/apps/sim/lib/billing/core/billing.test.ts new file mode 100644 index 000000000..cf9223a9f --- /dev/null +++ b/apps/sim/lib/billing/core/billing.test.ts @@ -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()) + }) + }) +}) diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts new file mode 100644 index 000000000..47d332e37 --- /dev/null +++ b/apps/sim/lib/billing/core/billing.ts @@ -0,0 +1,1053 @@ +import { and, eq } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { member, organization, subscription, user, userStats } from '@/db/schema' +import { requireStripeClient } from '../stripe-client' +import { resetOrganizationBillingPeriod, resetUserBillingPeriod } from './billing-periods' +import { getHighestPrioritySubscription } from './subscription' +import { getUserUsageData } from './usage' + +const logger = createLogger('Billing') + +interface BillingResult { + success: boolean + chargedAmount?: number + invoiceId?: string + error?: string +} + +/** + * BILLING MODEL: + * 1. User purchases $20 Pro plan โ†’ Gets charged $20 immediately via Stripe subscription + * 2. User uses $15 during the month โ†’ No additional charge (covered by $20) + * 3. User uses $35 during the month โ†’ Gets charged $15 overage at month end + * 4. Usage resets, next month they pay $20 again + any overages + */ + +/** + * Get plan pricing information + */ +export function getPlanPricing( + plan: string, + subscription?: any +): { + basePrice: number // What they pay upfront via Stripe subscription + minimum: number // Minimum they're guaranteed to pay +} { + switch (plan) { + case 'free': + return { basePrice: 0, minimum: 0 } // Free plan has no charges + case 'pro': + return { basePrice: 20, minimum: 20 } // $20/month subscription + case 'team': + return { basePrice: 40, minimum: 40 } // $40/seat/month subscription + case 'enterprise': + // Get per-seat pricing from metadata + if (subscription?.metadata) { + const metadata = + typeof subscription.metadata === 'string' + ? JSON.parse(subscription.metadata) + : subscription.metadata + + // Validate perSeatAllowance is a positive number + const perSeatAllowance = metadata.perSeatAllowance + const perSeatPrice = + typeof perSeatAllowance === 'number' && perSeatAllowance > 0 ? perSeatAllowance : 100 // Fall back to default for invalid values + + return { basePrice: perSeatPrice, minimum: perSeatPrice } + } + return { basePrice: 100, minimum: 100 } // Default enterprise pricing + default: + return { basePrice: 0, minimum: 0 } + } +} + +/** + * Get Stripe customer ID for a user or organization + */ +async function getStripeCustomerId(referenceId: string): Promise { + try { + // First check if it's a user + const userRecord = await db + .select({ stripeCustomerId: user.stripeCustomerId }) + .from(user) + .where(eq(user.id, referenceId)) + .limit(1) + + if (userRecord.length > 0 && userRecord[0].stripeCustomerId) { + return userRecord[0].stripeCustomerId + } + + // Check if it's an organization + const orgRecord = await db + .select({ metadata: organization.metadata }) + .from(organization) + .where(eq(organization.id, referenceId)) + .limit(1) + + if (orgRecord.length > 0 && orgRecord[0].metadata) { + const metadata = + typeof orgRecord[0].metadata === 'string' + ? JSON.parse(orgRecord[0].metadata) + : orgRecord[0].metadata + + if (metadata?.stripeCustomerId) { + return metadata.stripeCustomerId + } + } + + return null + } catch (error) { + logger.error('Failed to get Stripe customer ID', { referenceId, error }) + return null + } +} + +/** + * Create a Stripe invoice for overage billing only + */ +export async function createOverageBillingInvoice( + customerId: string, + overageAmount: number, + description: string, + metadata: Record = {} +): Promise { + try { + if (overageAmount <= 0) { + logger.info('No overage to bill', { customerId, overageAmount }) + return { success: true, chargedAmount: 0 } + } + + const stripeClient = requireStripeClient() + + // Check for existing overage invoice for this billing period + const billingPeriod = metadata.billingPeriod || new Date().toISOString().slice(0, 7) + + // Get the start of the billing period month for filtering + const periodStart = new Date(`${billingPeriod}-01`) + const periodStartTimestamp = Math.floor(periodStart.getTime() / 1000) + + // Look for invoices created in the last 35 days to cover month boundaries + const recentInvoices = await stripeClient.invoices.list({ + customer: customerId, + created: { + gte: periodStartTimestamp, + }, + limit: 100, + }) + + // Check if we already have an overage invoice for this period + const existingOverageInvoice = recentInvoices.data.find( + (invoice) => + invoice.metadata?.type === 'overage_billing' && + invoice.metadata?.billingPeriod === billingPeriod && + invoice.status !== 'void' // Ignore voided invoices + ) + + if (existingOverageInvoice) { + logger.warn('Overage invoice already exists for this billing period', { + customerId, + billingPeriod, + existingInvoiceId: existingOverageInvoice.id, + existingInvoiceStatus: existingOverageInvoice.status, + existingAmount: existingOverageInvoice.amount_due / 100, + }) + + // Return success but with no charge to prevent duplicate billing + return { + success: true, + chargedAmount: 0, + invoiceId: existingOverageInvoice.id, + } + } + + // Get customer to ensure they have an email set + const customer = await stripeClient.customers.retrieve(customerId) + if (!('email' in customer) || !customer.email) { + logger.warn('Customer does not have an email set, Stripe will not send automatic emails', { + customerId, + }) + } + + const invoiceItem = await stripeClient.invoiceItems.create({ + customer: customerId, + amount: Math.round(overageAmount * 100), // Convert to cents + currency: 'usd', + description, + metadata: { + ...metadata, + type: 'overage_billing', + }, + }) + + logger.info('Created overage invoice item', { + customerId, + amount: overageAmount, + invoiceItemId: invoiceItem.id, + }) + + // Create invoice that will include the invoice item + const invoice = await stripeClient.invoices.create({ + customer: customerId, + auto_advance: true, // Automatically finalize + collection_method: 'charge_automatically', // Charge immediately + metadata: { + ...metadata, + type: 'overage_billing', + }, + description, + pending_invoice_items_behavior: 'include', // Explicitly include pending items + payment_settings: { + payment_method_types: ['card'], // Accept card payments + }, + }) + + logger.info('Created overage invoice', { + customerId, + invoiceId: invoice.id, + amount: overageAmount, + status: invoice.status, + }) + + // If invoice is still draft (shouldn't happen with auto_advance), finalize it + let finalInvoice = invoice + if (invoice.status === 'draft') { + logger.warn('Invoice created as draft, manually finalizing', { invoiceId: invoice.id }) + finalInvoice = await stripeClient.invoices.finalizeInvoice(invoice.id) + logger.info('Manually finalized invoice', { + invoiceId: finalInvoice.id, + status: finalInvoice.status, + }) + } + + // If invoice is open (finalized but not paid), attempt to pay it + if (finalInvoice.status === 'open') { + try { + logger.info('Attempting to pay open invoice', { invoiceId: finalInvoice.id }) + const paidInvoice = await stripeClient.invoices.pay(finalInvoice.id) + logger.info('Successfully paid invoice', { + invoiceId: paidInvoice.id, + status: paidInvoice.status, + amountPaid: paidInvoice.amount_paid / 100, + }) + finalInvoice = paidInvoice + } catch (paymentError) { + logger.error('Failed to automatically pay invoice', { + invoiceId: finalInvoice.id, + error: paymentError, + }) + // Don't fail the whole operation if payment fails + // Stripe will retry and send payment failure notifications + } + } + + // Log final invoice status + logger.info('Invoice processing complete', { + customerId, + invoiceId: finalInvoice.id, + chargedAmount: overageAmount, + description, + status: finalInvoice.status, + paymentAttempted: finalInvoice.status === 'paid' || finalInvoice.attempted, + }) + + return { + success: true, + chargedAmount: overageAmount, + invoiceId: finalInvoice.id, + } + } catch (error) { + logger.error('Failed to create overage billing invoice', { + customerId, + overageAmount, + description, + error, + }) + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + } + } +} + +/** + * Calculate overage billing for a user + * Returns only the amount that exceeds their subscription base price + */ +export async function calculateUserOverage(userId: string): Promise<{ + basePrice: number + actualUsage: number + overageAmount: number + plan: string +} | null> { + try { + // Get user's subscription and usage data + const [subscription, usageData, userRecord] = await Promise.all([ + getHighestPrioritySubscription(userId), + getUserUsageData(userId), + db.select().from(user).where(eq(user.id, userId)).limit(1), + ]) + + if (userRecord.length === 0) { + logger.warn('User not found for overage calculation', { userId }) + return null + } + + const plan = subscription?.plan || 'free' + const { basePrice } = getPlanPricing(plan, subscription) + const actualUsage = usageData.currentUsage + + // Calculate overage: any usage beyond what they already paid for + const overageAmount = Math.max(0, actualUsage - basePrice) + + return { + basePrice, + actualUsage, + overageAmount, + plan, + } + } catch (error) { + logger.error('Failed to calculate user overage', { userId, error }) + return null + } +} + +/** + * Process overage billing for an individual user + */ +export async function processUserOverageBilling(userId: string): Promise { + try { + const overageInfo = await calculateUserOverage(userId) + + if (!overageInfo) { + return { success: false, error: 'Failed to calculate overage information' } + } + + // Skip billing for free plan users + if (overageInfo.plan === 'free') { + logger.info('Skipping overage billing for free plan user', { userId }) + return { success: true, chargedAmount: 0 } + } + + // Skip if no overage + if (overageInfo.overageAmount <= 0) { + logger.info('No overage to bill for user', { + userId, + basePrice: overageInfo.basePrice, + actualUsage: overageInfo.actualUsage, + }) + + // Still reset billing period even if no overage + try { + await resetUserBillingPeriod(userId) + } catch (resetError) { + logger.error('Failed to reset billing period', { userId, error: resetError }) + } + + return { success: true, chargedAmount: 0 } + } + + // Get Stripe customer ID + const stripeCustomerId = await getStripeCustomerId(userId) + if (!stripeCustomerId) { + logger.error('No Stripe customer ID found for user', { userId }) + return { success: false, error: 'No Stripe customer ID found' } + } + + // Get user email to ensure Stripe customer has it set + const userRecord = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + if (userRecord[0]?.email) { + // Update Stripe customer with email if needed + const stripeClient = requireStripeClient() + try { + await stripeClient.customers.update(stripeCustomerId, { + email: userRecord[0].email, + }) + logger.info('Updated Stripe customer with email', { + userId, + stripeCustomerId, + email: userRecord[0].email, + }) + } catch (updateError) { + logger.warn('Failed to update Stripe customer email', { + userId, + stripeCustomerId, + error: updateError, + }) + } + } + + const description = `Usage overage for ${overageInfo.plan} plan - $${overageInfo.overageAmount.toFixed(2)} above $${overageInfo.basePrice} base` + const metadata = { + userId, + plan: overageInfo.plan, + basePrice: overageInfo.basePrice.toString(), + actualUsage: overageInfo.actualUsage.toString(), + overageAmount: overageInfo.overageAmount.toString(), + billingPeriod: new Date().toISOString().slice(0, 7), // YYYY-MM format + } + + const result = await createOverageBillingInvoice( + stripeCustomerId, + overageInfo.overageAmount, + description, + metadata + ) + + // If billing was successful, reset the user's billing period + if (result.success) { + try { + await resetUserBillingPeriod(userId) + logger.info('Successfully reset billing period after charging user overage', { userId }) + } catch (resetError) { + logger.error('Failed to reset billing period after successful overage charge', { + userId, + error: resetError, + }) + } + } + + return result + } catch (error) { + logger.error('Failed to process user overage billing', { userId, error }) + return { success: false, error: 'Failed to process overage billing' } + } +} + +/** + * Process overage billing for an organization (team/enterprise plans) + */ +export async function processOrganizationOverageBilling( + organizationId: string +): Promise { + try { + // Get organization subscription + const subscription = await getHighestPrioritySubscription(organizationId) + + if (!subscription || !['team', 'enterprise'].includes(subscription.plan)) { + logger.warn('No team/enterprise subscription found for organization', { organizationId }) + return { success: false, error: 'No valid subscription found' } + } + + // Get organization's Stripe customer ID + const stripeCustomerId = await getStripeCustomerId(organizationId) + if (!stripeCustomerId) { + logger.error('No Stripe customer ID found for organization', { organizationId }) + return { success: false, error: 'No Stripe customer ID found' } + } + + // Get organization owner's email for billing + const orgOwner = await db + .select({ + userId: member.userId, + userEmail: user.email, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) + .limit(1) + + if (orgOwner[0]?.userEmail) { + // Update Stripe customer with organization owner's email + const stripeClient = requireStripeClient() + try { + await stripeClient.customers.update(stripeCustomerId, { + email: orgOwner[0].userEmail, + }) + logger.info('Updated Stripe customer with organization owner email', { + organizationId, + stripeCustomerId, + email: orgOwner[0].userEmail, + }) + } catch (updateError) { + logger.warn('Failed to update Stripe customer email for organization', { + organizationId, + stripeCustomerId, + error: updateError, + }) + } + } + + // Get all organization members + const members = await db + .select({ + userId: member.userId, + userName: user.name, + userEmail: user.email, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(eq(member.organizationId, organizationId)) + + if (members.length === 0) { + logger.info('No members found for organization overage billing', { organizationId }) + return { success: true, chargedAmount: 0 } + } + + // Calculate total team usage across all members + const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan, subscription) + const licensedSeats = subscription.seats || 1 + const baseSubscriptionAmount = licensedSeats * basePricePerSeat // What Stripe already charged + + let totalTeamUsage = 0 + const memberUsageDetails = [] + + for (const memberInfo of members) { + const usageData = await getUserUsageData(memberInfo.userId) + totalTeamUsage += usageData.currentUsage + + memberUsageDetails.push({ + userId: memberInfo.userId, + name: memberInfo.userName, + email: memberInfo.userEmail, + usage: usageData.currentUsage, + }) + } + + // Calculate team-level overage: total usage beyond what was already paid to Stripe + const totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount) + + // Skip if no overage across the organization + if (totalOverage <= 0) { + logger.info('No overage to bill for organization', { + organizationId, + licensedSeats, + memberCount: members.length, + totalTeamUsage, + baseSubscriptionAmount, + }) + + // Still reset billing period for all members + try { + await resetOrganizationBillingPeriod(organizationId) + } catch (resetError) { + logger.error('Failed to reset organization billing period', { + organizationId, + error: resetError, + }) + } + + return { success: true, chargedAmount: 0 } + } + + // Create consolidated overage invoice for the organization + const description = `Team usage overage for ${subscription.plan} plan - ${licensedSeats} licensed seats, $${totalTeamUsage.toFixed(2)} total usage, $${totalOverage.toFixed(2)} overage` + const metadata = { + organizationId, + plan: subscription.plan, + licensedSeats: licensedSeats.toString(), + memberCount: members.length.toString(), + basePricePerSeat: basePricePerSeat.toString(), + baseSubscriptionAmount: baseSubscriptionAmount.toString(), + totalTeamUsage: totalTeamUsage.toString(), + totalOverage: totalOverage.toString(), + billingPeriod: new Date().toISOString().slice(0, 7), // YYYY-MM format + memberDetails: JSON.stringify(memberUsageDetails), + } + + const result = await createOverageBillingInvoice( + stripeCustomerId, + totalOverage, + description, + metadata + ) + + // If billing was successful, reset billing period for all organization members + if (result.success) { + try { + await resetOrganizationBillingPeriod(organizationId) + logger.info('Successfully reset billing period for organization after overage billing', { + organizationId, + memberCount: members.length, + }) + } catch (resetError) { + logger.error( + 'Failed to reset organization billing period after successful overage charge', + { + organizationId, + error: resetError, + } + ) + } + } + + logger.info('Processed organization overage billing', { + organizationId, + memberCount: members.length, + totalOverage, + result, + }) + + return result + } catch (error) { + logger.error('Failed to process organization overage billing', { organizationId, error }) + return { success: false, error: 'Failed to process organization overage billing' } + } +} + +/** + * Get users and organizations whose billing periods end today + */ +export async function getUsersAndOrganizationsForOverageBilling(): Promise<{ + users: string[] + organizations: string[] +}> { + try { + const today = new Date() + today.setUTCHours(0, 0, 0, 0) // Start of today + const tomorrow = new Date(today) + tomorrow.setUTCDate(tomorrow.getUTCDate() + 1) // Start of tomorrow + + logger.info('Checking for subscriptions with billing periods ending today', { + today: today.toISOString(), + tomorrow: tomorrow.toISOString(), + }) + + // Get all active subscriptions (excluding free plans) + const activeSubscriptions = await db + .select() + .from(subscription) + .where(eq(subscription.status, 'active')) + + const users: string[] = [] + const organizations: string[] = [] + + for (const sub of activeSubscriptions) { + if (sub.plan === 'free') { + continue // Skip free plans + } + + // Check if subscription period ends today + let shouldBillToday = false + + if (sub.periodEnd) { + const periodEnd = new Date(sub.periodEnd) + periodEnd.setUTCHours(0, 0, 0, 0) // Normalize to start of day + + // Bill if the subscription period ends today + if (periodEnd.getTime() === today.getTime()) { + shouldBillToday = true + logger.info('Subscription period ends today', { + referenceId: sub.referenceId, + plan: sub.plan, + periodEnd: sub.periodEnd, + }) + } + } else { + // Fallback: Check userStats billing period for users + const userStatsRecord = await db + .select({ + billingPeriodEnd: userStats.billingPeriodEnd, + }) + .from(userStats) + .where(eq(userStats.userId, sub.referenceId)) + .limit(1) + + if (userStatsRecord.length > 0 && userStatsRecord[0].billingPeriodEnd) { + const billingPeriodEnd = new Date(userStatsRecord[0].billingPeriodEnd) + billingPeriodEnd.setUTCHours(0, 0, 0, 0) // Normalize to start of day + + if (billingPeriodEnd.getTime() === today.getTime()) { + shouldBillToday = true + logger.info('User billing period ends today (from userStats)', { + userId: sub.referenceId, + plan: sub.plan, + billingPeriodEnd: userStatsRecord[0].billingPeriodEnd, + }) + } + } + } + + if (shouldBillToday) { + // Check if referenceId is a user or organization + const userExists = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.id, sub.referenceId)) + .limit(1) + + if (userExists.length > 0) { + // It's a user subscription (pro plan) + users.push(sub.referenceId) + } else { + // Check if it's an organization + const orgExists = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, sub.referenceId)) + .limit(1) + + if (orgExists.length > 0) { + // It's an organization subscription (team/enterprise) + organizations.push(sub.referenceId) + } + } + } + } + + logger.info('Found entities for daily billing check', { + userCount: users.length, + organizationCount: organizations.length, + users, + organizations, + }) + + return { users, organizations } + } catch (error) { + logger.error('Failed to get entities for daily billing check', { error }) + return { users: [], organizations: [] } + } +} + +/** + * Get comprehensive billing and subscription summary + */ +export async function getSimplifiedBillingSummary( + userId: string, + organizationId?: string +): Promise<{ + type: 'individual' | 'organization' + plan: string + basePrice: number + currentUsage: number + overageAmount: number + totalProjected: number + usageLimit: number + percentUsed: number + isWarning: boolean + isExceeded: boolean + daysRemaining: number + // Subscription details + isPaid: boolean + isPro: boolean + isTeam: boolean + isEnterprise: boolean + status: string | null + seats: number | null + metadata: any + stripeSubscriptionId: string | null + periodEnd: Date | string | null + // Usage details + usage: { + current: number + limit: number + percentUsed: number + isWarning: boolean + isExceeded: boolean + billingPeriodStart: Date | null + billingPeriodEnd: Date | null + lastPeriodCost: number + daysRemaining: number + } + organizationData?: { + seatCount: number + totalBasePrice: number + totalCurrentUsage: number + totalOverage: number + } +}> { + try { + // Get subscription and usage data upfront + const [subscription, usageData] = await Promise.all([ + getHighestPrioritySubscription(organizationId || userId), + getUserUsageData(userId), + ]) + + // Determine subscription type flags + const plan = subscription?.plan || 'free' + const isPaid = plan !== 'free' + const isPro = plan === 'pro' + const isTeam = plan === 'team' + const isEnterprise = plan === 'enterprise' + + if (organizationId) { + // Organization billing summary + if (!subscription) { + return getDefaultBillingSummary('organization') + } + + // Get all organization members + const members = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + + const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan, subscription) + const licensedSeats = subscription.seats || 1 + const totalBasePrice = basePricePerSeat * licensedSeats // Based on licensed seats, not member count + + let totalCurrentUsage = 0 + + // Calculate total team usage across all members + for (const memberInfo of members) { + const memberUsageData = await getUserUsageData(memberInfo.userId) + totalCurrentUsage += memberUsageData.currentUsage + } + + // Calculate team-level overage: total usage beyond what was already paid to Stripe + const totalOverage = Math.max(0, totalCurrentUsage - totalBasePrice) + + // Get user's personal limits for warnings + const percentUsed = + usageData.limit > 0 ? Math.round((usageData.currentUsage / usageData.limit) * 100) : 0 + + // Calculate days remaining in billing period + const daysRemaining = usageData.billingPeriodEnd + ? Math.max( + 0, + Math.ceil((usageData.billingPeriodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + ) + : 0 + + return { + type: 'organization', + plan: subscription.plan, + basePrice: totalBasePrice, + currentUsage: totalCurrentUsage, + overageAmount: totalOverage, + totalProjected: totalBasePrice + totalOverage, + usageLimit: usageData.limit, + percentUsed, + isWarning: percentUsed >= 80 && percentUsed < 100, + isExceeded: usageData.currentUsage >= usageData.limit, + daysRemaining, + // Subscription details + isPaid, + isPro, + isTeam, + isEnterprise, + status: subscription.status || null, + seats: subscription.seats || null, + metadata: subscription.metadata || null, + stripeSubscriptionId: subscription.stripeSubscriptionId || null, + periodEnd: subscription.periodEnd || null, + // Usage details + usage: { + current: usageData.currentUsage, + limit: usageData.limit, + percentUsed, + isWarning: percentUsed >= 80 && percentUsed < 100, + isExceeded: usageData.currentUsage >= usageData.limit, + billingPeriodStart: usageData.billingPeriodStart, + billingPeriodEnd: usageData.billingPeriodEnd, + lastPeriodCost: usageData.lastPeriodCost, + daysRemaining, + }, + organizationData: { + seatCount: licensedSeats, + totalBasePrice, + totalCurrentUsage, + totalOverage, + }, + } + } + + // Individual billing summary + const { basePrice } = getPlanPricing(plan, subscription) + const overageAmount = Math.max(0, usageData.currentUsage - basePrice) + const percentUsed = + usageData.limit > 0 ? Math.round((usageData.currentUsage / usageData.limit) * 100) : 0 + + // Calculate days remaining in billing period + const daysRemaining = usageData.billingPeriodEnd + ? Math.max( + 0, + Math.ceil((usageData.billingPeriodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + ) + : 0 + + return { + type: 'individual', + plan, + basePrice, + currentUsage: usageData.currentUsage, + overageAmount, + totalProjected: basePrice + overageAmount, + usageLimit: usageData.limit, + percentUsed, + isWarning: percentUsed >= 80 && percentUsed < 100, + isExceeded: usageData.currentUsage >= usageData.limit, + daysRemaining, + // Subscription details + isPaid, + isPro, + isTeam, + isEnterprise, + status: subscription?.status || null, + seats: subscription?.seats || null, + metadata: subscription?.metadata || null, + stripeSubscriptionId: subscription?.stripeSubscriptionId || null, + periodEnd: subscription?.periodEnd || null, + // Usage details + usage: { + current: usageData.currentUsage, + limit: usageData.limit, + percentUsed, + isWarning: percentUsed >= 80 && percentUsed < 100, + isExceeded: usageData.currentUsage >= usageData.limit, + billingPeriodStart: usageData.billingPeriodStart, + billingPeriodEnd: usageData.billingPeriodEnd, + lastPeriodCost: usageData.lastPeriodCost, + daysRemaining, + }, + } + } catch (error) { + logger.error('Failed to get simplified billing summary', { userId, organizationId, error }) + return getDefaultBillingSummary(organizationId ? 'organization' : 'individual') + } +} + +/** + * Get default billing summary for error cases + */ +function getDefaultBillingSummary(type: 'individual' | 'organization') { + return { + type, + plan: 'free', + basePrice: 0, + currentUsage: 0, + overageAmount: 0, + totalProjected: 0, + usageLimit: 5, + percentUsed: 0, + isWarning: false, + isExceeded: false, + daysRemaining: 0, + // Subscription details + isPaid: false, + isPro: false, + isTeam: false, + isEnterprise: false, + status: null, + seats: null, + metadata: null, + stripeSubscriptionId: null, + periodEnd: null, + // Usage details + usage: { + current: 0, + limit: 5, + percentUsed: 0, + isWarning: false, + isExceeded: false, + billingPeriodStart: null, + billingPeriodEnd: null, + lastPeriodCost: 0, + daysRemaining: 0, + }, + } +} + +/** + * Process daily billing check for users and organizations with periods ending today + */ +export async function processDailyBillingCheck(): Promise<{ + success: boolean + processedUsers: number + processedOrganizations: number + totalChargedAmount: number + errors: string[] +}> { + try { + logger.info('Starting daily billing check process') + + const { users, organizations } = await getUsersAndOrganizationsForOverageBilling() + + let processedUsers = 0 + let processedOrganizations = 0 + let totalChargedAmount = 0 + const errors: string[] = [] + + // Process individual users (pro plans) + for (const userId of users) { + try { + const result = await processUserOverageBilling(userId) + if (result.success) { + processedUsers++ + totalChargedAmount += result.chargedAmount || 0 + logger.info('Successfully processed user overage billing', { + userId, + chargedAmount: result.chargedAmount, + }) + } else { + errors.push(`User ${userId}: ${result.error}`) + logger.error('Failed to process user overage billing', { userId, error: result.error }) + } + } catch (error) { + const errorMsg = `User ${userId}: ${error instanceof Error ? error.message : 'Unknown error'}` + errors.push(errorMsg) + logger.error('Exception during user overage billing', { userId, error }) + } + } + + // Process organizations (team/enterprise plans) + for (const organizationId of organizations) { + try { + const result = await processOrganizationOverageBilling(organizationId) + if (result.success) { + processedOrganizations++ + totalChargedAmount += result.chargedAmount || 0 + logger.info('Successfully processed organization overage billing', { + organizationId, + chargedAmount: result.chargedAmount, + }) + } else { + errors.push(`Organization ${organizationId}: ${result.error}`) + logger.error('Failed to process organization overage billing', { + organizationId, + error: result.error, + }) + } + } catch (error) { + const errorMsg = `Organization ${organizationId}: ${error instanceof Error ? error.message : 'Unknown error'}` + errors.push(errorMsg) + logger.error('Exception during organization overage billing', { organizationId, error }) + } + } + + logger.info('Completed daily billing check process', { + processedUsers, + processedOrganizations, + totalChargedAmount, + errorCount: errors.length, + }) + + return { + success: errors.length === 0, + processedUsers, + processedOrganizations, + totalChargedAmount, + errors, + } + } catch (error) { + logger.error('Fatal error during daily billing check process', { error }) + return { + success: false, + processedUsers: 0, + processedOrganizations: 0, + totalChargedAmount: 0, + errors: [error instanceof Error ? error.message : 'Fatal daily billing check process error'], + } + } +} + +/** + * Legacy function for backward compatibility - now redirects to daily billing check + * @deprecated Use processDailyBillingCheck instead + */ +export async function processMonthlyOverageBilling(): Promise<{ + success: boolean + processedUsers: number + processedOrganizations: number + totalChargedAmount: number + errors: string[] +}> { + logger.warn('processMonthlyOverageBilling is deprecated, use processDailyBillingCheck instead') + return processDailyBillingCheck() +} diff --git a/apps/sim/lib/billing/core/organization-billing.ts b/apps/sim/lib/billing/core/organization-billing.ts new file mode 100644 index 000000000..02f4b268d --- /dev/null +++ b/apps/sim/lib/billing/core/organization-billing.ts @@ -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 { + 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 { + 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 + } +} diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts new file mode 100644 index 000000000..90277a31f --- /dev/null +++ b/apps/sim/lib/billing/core/subscription.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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', + } + } +} diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts new file mode 100644 index 000000000..4cbd83469 --- /dev/null +++ b/apps/sim/lib/billing/core/usage.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts new file mode 100644 index 000000000..c02347e51 --- /dev/null +++ b/apps/sim/lib/billing/index.ts @@ -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' diff --git a/apps/sim/lib/billing/stripe-client.ts b/apps/sim/lib/billing/stripe-client.ts new file mode 100644 index 000000000..ea214f0f5 --- /dev/null +++ b/apps/sim/lib/billing/stripe-client.ts @@ -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 +} diff --git a/apps/sim/lib/subscription/utils.test.ts b/apps/sim/lib/billing/subscriptions/utils.test.ts similarity index 71% rename from apps/sim/lib/subscription/utils.test.ts rename to apps/sim/lib/billing/subscriptions/utils.test.ts index 7f8b6e96c..fdda8ec38 100644 --- a/apps/sim/lib/subscription/utils.test.ts +++ b/apps/sim/lib/billing/subscriptions/utils.test.ts @@ -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) }) }) }) diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts new file mode 100644 index 000000000..2143f5499 --- /dev/null +++ b/apps/sim/lib/billing/subscriptions/utils.ts @@ -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) +} diff --git a/apps/sim/lib/billing/types/index.ts b/apps/sim/lib/billing/types/index.ts new file mode 100644 index 000000000..6bb526be0 --- /dev/null +++ b/apps/sim/lib/billing/types/index.ts @@ -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 + 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 +} diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts new file mode 100644 index 000000000..2cbef45c1 --- /dev/null +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -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 { + 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 { + 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 + } +} diff --git a/apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts b/apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts new file mode 100644 index 000000000..cbb732873 --- /dev/null +++ b/apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts @@ -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 }) + } +} diff --git a/apps/sim/lib/logs/enhanced-execution-logger.ts b/apps/sim/lib/logs/enhanced-execution-logger.ts index fcb85b456..ba12d4e68 100644 --- a/apps/sim/lib/logs/enhanced-execution-logger.ts +++ b/apps/sim/lib/logs/enhanced-execution-logger.ts @@ -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 + output?: Record + 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 { 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 { + 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': diff --git a/apps/sim/lib/logs/execution-logger.ts b/apps/sim/lib/logs/execution-logger.ts index b1086090a..29bb9c624 100644 --- a/apps/sim/lib/logs/execution-logger.ts +++ b/apps/sim/lib/logs/execution-logger.ts @@ -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)) diff --git a/apps/sim/lib/permissions/utils.ts b/apps/sim/lib/permissions/utils.ts index aa433b33b..d6532e075 100644 --- a/apps/sim/lib/permissions/utils.ts +++ b/apps/sim/lib/permissions/utils.ts @@ -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 - True if the user is an organization admin with access to the workspace + */ +export async function isOrganizationAdminForWorkspace( + userId: string, + workspaceId: string +): Promise { + 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 - True if the user has admin permission for the workspace, false otherwise + */ +export async function hasWorkspaceAdminAccess( + userId: string, + workspaceId: string +): Promise { + // 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 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 - True if the user is an owner or admin of the organization + */ +export async function isOrganizationOwnerOrAdmin( + userId: string, + organizationId: string +): Promise { + 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 + } +} diff --git a/apps/sim/lib/subscription.ts b/apps/sim/lib/subscription.ts deleted file mode 100644 index fae02ccad..000000000 --- a/apps/sim/lib/subscription.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 - } -} diff --git a/apps/sim/lib/subscription/subscription.ts b/apps/sim/lib/subscription/subscription.ts deleted file mode 100644 index d82f1b0cb..000000000 --- a/apps/sim/lib/subscription/subscription.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 -} diff --git a/apps/sim/lib/subscription/utils.ts b/apps/sim/lib/subscription/utils.ts deleted file mode 100644 index 360fd0e38..000000000 --- a/apps/sim/lib/subscription/utils.ts +++ /dev/null @@ -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 -} diff --git a/apps/sim/lib/waitlist/rate-limiter.ts b/apps/sim/lib/waitlist/rate-limiter.ts deleted file mode 100644 index a1cb896de..000000000 --- a/apps/sim/lib/waitlist/rate-limiter.ts +++ /dev/null @@ -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 } -} diff --git a/apps/sim/lib/waitlist/service.ts b/apps/sim/lib/waitlist/service.ts deleted file mode 100644 index f4e54fb8a..000000000 --- a/apps/sim/lib/waitlist/service.ts +++ /dev/null @@ -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 { - 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', - })), - } - } -} diff --git a/apps/sim/lib/waitlist/token.ts b/apps/sim/lib/waitlist/token.ts deleted file mode 100644 index 3089cf28c..000000000 --- a/apps/sim/lib/waitlist/token.ts +++ /dev/null @@ -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 { - 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 { - try { - const { payload } = await jwtVerify(token, getJwtSecret()) - - return payload as unknown as DecodedToken - } catch (error) { - console.error('Error verifying token:', error) - return null - } -} diff --git a/apps/sim/package.json b/apps/sim/package.json index 2348cc74b..0885cf80e 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -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", diff --git a/apps/sim/scripts/test-billing-suite.ts b/apps/sim/scripts/test-billing-suite.ts new file mode 100644 index 000000000..322c5522a --- /dev/null +++ b/apps/sim/scripts/test-billing-suite.ts @@ -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 { + 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 { + 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 { + 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 { + 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) +}) diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index d5aa173c2..4f10bf92f 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -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 diff --git a/apps/sim/stores/organization/index.ts b/apps/sim/stores/organization/index.ts new file mode 100644 index 000000000..945de6280 --- /dev/null +++ b/apps/sim/stores/organization/index.ts @@ -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' diff --git a/apps/sim/stores/organization/store.ts b/apps/sim/stores/organization/store.ts new file mode 100644 index 000000000..5abb75097 --- /dev/null +++ b/apps/sim/stores/organization/store.ts @@ -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()( + 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() +} diff --git a/apps/sim/stores/organization/types.ts b/apps/sim/stores/organization/types.ts new file mode 100644 index 000000000..a5946d64c --- /dev/null +++ b/apps/sim/stores/organization/types.ts @@ -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 + loadOrganizationSubscription: (orgId: string) => Promise + loadOrganizationBillingData: (organizationId: string) => Promise + loadUserWorkspaces: (userId?: string) => Promise + refreshOrganization: () => Promise + + // Organization management + createOrganization: (name: string, slug: string) => Promise + setActiveOrganization: (orgId: string) => Promise + updateOrganizationSettings: () => Promise + + // Team management + inviteMember: (email: string, workspaceInvitations?: WorkspaceInvitation[]) => Promise + removeMember: (memberId: string, shouldReduceSeats?: boolean) => Promise + cancelInvitation: (invitationId: string) => Promise + updateMemberUsageLimit: ( + userId: string, + organizationId: string, + newLimit: number + ) => Promise<{ success: boolean; error?: string }> + + // Seat management + addSeats: (newSeatCount: number) => Promise + reduceSeats: (newSeatCount: number) => Promise + + transferSubscriptionToOrganization: (orgId: string) => Promise + + getUserRole: (userEmail?: string) => string + isAdminOrOwner: (userEmail?: string) => boolean + getUsedSeats: () => { used: number; members: number; pending: number } + + setOrgFormData: (data: Partial) => void + + clearError: () => void + clearSuccessMessages: () => void +} diff --git a/apps/sim/stores/organization/utils.ts b/apps/sim/stores/organization/utils.ts new file mode 100644 index 000000000..00202cdb8 --- /dev/null +++ b/apps/sim/stores/organization/utils.ts @@ -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 +} diff --git a/apps/sim/stores/settings/general/store.ts b/apps/sim/stores/settings/general/store.ts index 573611e81..d2c1f178b 100644 --- a/apps/sim/stores/settings/general/store.ts +++ b/apps/sim/stores/settings/general/store.ts @@ -91,7 +91,7 @@ export const useGeneralStore = create()( try { set({ isLoading: true, error: null }) - const response = await fetch('/api/user/settings') + const response = await fetch('/api/users/me/settings') if (!response.ok) { throw new Error('Failed to fetch settings') @@ -135,7 +135,7 @@ export const useGeneralStore = create()( } try { - const response = await fetch('/api/user/settings', { + const response = await fetch('/api/users/me/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [key]: value }), diff --git a/apps/sim/stores/subscription/store.ts b/apps/sim/stores/subscription/store.ts new file mode 100644 index 000000000..a34c16a0a --- /dev/null +++ b/apps/sim/stores/subscription/store.ts @@ -0,0 +1,489 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' +import { createLogger } from '@/lib/logs/console-logger' +import type { + BillingStatus, + SubscriptionData, + SubscriptionFeatures, + SubscriptionStore, + UsageData, + UsageLimitData, +} from './types' + +const logger = createLogger('SubscriptionStore') + +const CACHE_DURATION = 30 * 1000 + +const defaultFeatures: SubscriptionFeatures = { + sharingEnabled: false, + multiplayerEnabled: false, + workspaceCollaborationEnabled: false, +} + +const defaultUsage: UsageData = { + current: 0, + limit: 5, + percentUsed: 0, + isWarning: false, + isExceeded: false, + billingPeriodStart: null, + billingPeriodEnd: null, + lastPeriodCost: 0, +} + +export const useSubscriptionStore = create()( + devtools( + (set, get) => ({ + // State + subscriptionData: null, + usageLimitData: null, + isLoading: false, + error: null, + lastFetched: null, + + // Core actions + loadSubscriptionData: async () => { + const state = get() + + // Check cache validity + if ( + state.subscriptionData && + state.lastFetched && + Date.now() - state.lastFetched < CACHE_DURATION + ) { + logger.debug('Using cached subscription data') + return state.subscriptionData + } + + // Don't start multiple concurrent requests + if (state.isLoading) { + logger.debug('Subscription data already loading, skipping duplicate request') + return get().subscriptionData + } + + set({ isLoading: true, error: null }) + + try { + 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 data = result.data + + // Transform dates with error handling + const transformedData: SubscriptionData = { + ...data, + periodEnd: data.periodEnd + ? (() => { + try { + const date = new Date(data.periodEnd) + return Number.isNaN(date.getTime()) ? null : date + } catch { + return null + } + })() + : null, + usage: { + ...data.usage, + billingPeriodStart: data.usage?.billingPeriodStart + ? (() => { + try { + const date = new Date(data.usage.billingPeriodStart) + return Number.isNaN(date.getTime()) ? null : date + } catch { + return null + } + })() + : null, + billingPeriodEnd: data.usage?.billingPeriodEnd + ? (() => { + try { + const date = new Date(data.usage.billingPeriodEnd) + return Number.isNaN(date.getTime()) ? null : date + } catch { + return null + } + })() + : null, + }, + } + + // Debug logging for billing periods + logger.debug('Billing period data', { + raw: { + billingPeriodStart: data.usage?.billingPeriodStart, + billingPeriodEnd: data.usage?.billingPeriodEnd, + }, + transformed: { + billingPeriodStart: transformedData.usage.billingPeriodStart, + billingPeriodEnd: transformedData.usage.billingPeriodEnd, + }, + }) + + set({ + subscriptionData: transformedData, + isLoading: false, + error: null, + lastFetched: Date.now(), + }) + + logger.debug('Subscription data loaded successfully') + return transformedData + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to load subscription data' + logger.error('Failed to load subscription data', { error }) + + set({ + isLoading: false, + error: errorMessage, + }) + return null + } + }, + + loadUsageLimitData: async () => { + try { + const response = await fetch('/api/usage-limits?context=user') + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.json() + + // Transform dates + const transformedData: UsageLimitData = { + ...data, + updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined, + } + + set({ usageLimitData: transformedData }) + logger.debug('Usage limit data loaded successfully') + return transformedData + } catch (error) { + logger.error('Failed to load usage limit data', { error }) + // Don't set error state for usage limit failures - subscription data is more critical + return null + } + }, + + updateUsageLimit: 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') + } + + // Refresh the store state to ensure consistency + await get().refresh() + + logger.debug('Usage limit updated successfully', { newLimit }) + return { success: true } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to update usage limit' + logger.error('Failed to update usage limit', { error, newLimit }) + return { success: false, error: errorMessage } + } + }, + + cancelSubscription: async () => { + const state = get() + if (!state.subscriptionData) { + logger.error('No subscription data available for cancellation') + return { success: false, error: 'No subscription data available' } + } + + set({ isLoading: true, error: null }) + + try { + const response = await fetch('/api/users/me/subscription/cancel', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to cancel subscription') + } + + const result = await response.json() + + logger.info('Subscription cancelled successfully', { + periodEnd: result.data.periodEnd, + cancelAtPeriodEnd: result.data.cancelAtPeriodEnd, + }) + + // Refresh subscription data to reflect cancellation status + await get().refresh() + + return { + success: true, + periodEnd: result.data.periodEnd ? new Date(result.data.periodEnd) : undefined, + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to cancel subscription' + logger.error('Failed to cancel subscription', { error }) + set({ error: errorMessage }) + return { success: false, error: errorMessage } + } finally { + set({ isLoading: false }) + } + }, + + refresh: async () => { + // Force refresh by clearing cache + set({ lastFetched: null }) + await get().loadData() + }, + + // Load both subscription and usage limit data in parallel + loadData: async () => { + const state = get() + + // Check cache validity for subscription data + if ( + state.subscriptionData && + state.lastFetched && + Date.now() - state.lastFetched < CACHE_DURATION + ) { + logger.debug('Using cached data') + // Still load usage limit if not present + if (!state.usageLimitData) { + const usageLimitData = await get().loadUsageLimitData() + return { + subscriptionData: state.subscriptionData, + usageLimitData: usageLimitData, + } + } + return { + subscriptionData: state.subscriptionData, + usageLimitData: state.usageLimitData, + } + } + + // Don't start multiple concurrent requests + if (state.isLoading) { + logger.debug('Data already loading, skipping duplicate request') + return { + subscriptionData: get().subscriptionData, + usageLimitData: get().usageLimitData, + } + } + + set({ isLoading: true, error: null }) + + try { + // Load both subscription and usage limit data in parallel + const [subscriptionResponse, usageLimitResponse] = await Promise.all([ + fetch('/api/billing?context=user'), + fetch('/api/usage-limits?context=user'), + ]) + + if (!subscriptionResponse.ok) { + throw new Error(`HTTP error! status: ${subscriptionResponse.status}`) + } + + const subscriptionResult = await subscriptionResponse.json() + const subscriptionData = subscriptionResult.data + let usageLimitData = null + + if (usageLimitResponse.ok) { + usageLimitData = await usageLimitResponse.json() + } else { + logger.warn('Failed to load usage limit data, using defaults') + } + + // Transform subscription data dates with error handling + const transformedSubscriptionData: SubscriptionData = { + ...subscriptionData, + periodEnd: subscriptionData.periodEnd + ? (() => { + try { + const date = new Date(subscriptionData.periodEnd) + return Number.isNaN(date.getTime()) ? null : date + } catch { + return null + } + })() + : null, + usage: { + ...subscriptionData.usage, + billingPeriodStart: subscriptionData.usage?.billingPeriodStart + ? (() => { + try { + const date = new Date(subscriptionData.usage.billingPeriodStart) + return Number.isNaN(date.getTime()) ? null : date + } catch { + return null + } + })() + : null, + billingPeriodEnd: subscriptionData.usage?.billingPeriodEnd + ? (() => { + try { + const date = new Date(subscriptionData.usage.billingPeriodEnd) + return Number.isNaN(date.getTime()) ? null : date + } catch { + return null + } + })() + : null, + }, + } + + // Debug logging for parallel billing periods + logger.debug('Parallel billing period data', { + raw: { + billingPeriodStart: subscriptionData.usage?.billingPeriodStart, + billingPeriodEnd: subscriptionData.usage?.billingPeriodEnd, + }, + transformed: { + billingPeriodStart: transformedSubscriptionData.usage.billingPeriodStart, + billingPeriodEnd: transformedSubscriptionData.usage.billingPeriodEnd, + }, + }) + + // Transform usage limit data dates if present + const transformedUsageLimitData: UsageLimitData | null = usageLimitData + ? { + ...usageLimitData, + updatedAt: usageLimitData.updatedAt + ? new Date(usageLimitData.updatedAt) + : undefined, + } + : null + + set({ + subscriptionData: transformedSubscriptionData, + usageLimitData: transformedUsageLimitData, + isLoading: false, + error: null, + lastFetched: Date.now(), + }) + + logger.debug('Data loaded successfully in parallel') + return { + subscriptionData: transformedSubscriptionData, + usageLimitData: transformedUsageLimitData, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load data' + logger.error('Failed to load data', { error }) + + set({ + isLoading: false, + error: errorMessage, + }) + return { + subscriptionData: null, + usageLimitData: null, + } + } + }, + + clearError: () => { + set({ error: null }) + }, + + reset: () => { + set({ + subscriptionData: null, + usageLimitData: null, + isLoading: false, + error: null, + lastFetched: null, + }) + }, + + // Computed getters + getSubscriptionStatus: () => { + const data = get().subscriptionData + return { + 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 ?? null, + seats: data?.seats ?? null, + metadata: data?.metadata ?? null, + } + }, + + getFeatures: () => { + return get().subscriptionData?.features ?? defaultFeatures + }, + + getUsage: () => { + return get().subscriptionData?.usage ?? defaultUsage + }, + + getBillingStatus: (): BillingStatus => { + const usage = get().getUsage() + if (usage.isExceeded) return 'exceeded' + if (usage.isWarning) return 'warning' + return 'ok' + }, + + getRemainingBudget: () => { + const usage = get().getUsage() + return Math.max(0, usage.limit - usage.current) + }, + + getDaysRemainingInPeriod: () => { + const usage = get().getUsage() + if (!usage.billingPeriodEnd) return null + + const now = new Date() + const endDate = usage.billingPeriodEnd + const diffTime = endDate.getTime() - now.getTime() + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + + return Math.max(0, diffDays) + }, + + hasFeature: (feature: keyof SubscriptionFeatures) => { + return get().getFeatures()[feature] ?? false + }, + + isAtLeastPro: () => { + const status = get().getSubscriptionStatus() + return status.isPro || status.isTeam || status.isEnterprise + }, + + isAtLeastTeam: () => { + const status = get().getSubscriptionStatus() + return status.isTeam || status.isEnterprise + }, + + canUpgrade: () => { + const status = get().getSubscriptionStatus() + return status.plan === 'free' || status.plan === 'pro' + }, + }), + { name: 'subscription-store' } + ) +) + +// Auto-load subscription data when store is first accessed +if (typeof window !== 'undefined') { + // Load data in parallel on store creation + useSubscriptionStore.getState().loadData() +} diff --git a/apps/sim/stores/subscription/types.ts b/apps/sim/stores/subscription/types.ts new file mode 100644 index 000000000..3a7880ff0 --- /dev/null +++ b/apps/sim/stores/subscription/types.ts @@ -0,0 +1,81 @@ +export interface SubscriptionFeatures { + sharingEnabled: boolean + multiplayerEnabled: boolean + workspaceCollaborationEnabled: boolean +} + +export interface UsageData { + current: number + limit: number + percentUsed: number + isWarning: boolean + isExceeded: boolean + billingPeriodStart: Date | null + billingPeriodEnd: Date | null + lastPeriodCost: number +} + +export interface UsageLimitData { + currentLimit: number + canEdit: boolean + minimumLimit: number + plan: string + setBy?: string + updatedAt?: Date +} + +export interface SubscriptionData { + isPaid: boolean + isPro: boolean + isTeam: boolean + isEnterprise: boolean + plan: string + status: string | null + seats: number | null + metadata: any | null + stripeSubscriptionId: string | null + periodEnd: Date | null + features: SubscriptionFeatures + usage: UsageData +} + +export type BillingStatus = 'unknown' | 'ok' | 'warning' | 'exceeded' + +export interface SubscriptionStore { + subscriptionData: SubscriptionData | null + usageLimitData: UsageLimitData | null + isLoading: boolean + error: string | null + lastFetched: number | null + loadSubscriptionData: () => Promise + loadUsageLimitData: () => Promise + loadData: () => Promise<{ + subscriptionData: SubscriptionData | null + usageLimitData: UsageLimitData | null + }> + updateUsageLimit: (newLimit: number) => Promise<{ success: boolean; error?: string }> + cancelSubscription: () => Promise<{ success: boolean; error?: string; periodEnd?: Date }> + refresh: () => Promise + clearError: () => void + reset: () => void + getSubscriptionStatus: () => { + isPaid: boolean + isPro: boolean + isTeam: boolean + isEnterprise: boolean + isFree: boolean + plan: string + status: string | null + seats: number | null + metadata: any | null + } + getFeatures: () => SubscriptionFeatures + getUsage: () => UsageData + getBillingStatus: () => BillingStatus + getRemainingBudget: () => number + getDaysRemainingInPeriod: () => number | null + hasFeature: (feature: keyof SubscriptionFeatures) => boolean + isAtLeastPro: () => boolean + isAtLeastTeam: () => boolean + canUpgrade: () => boolean +} diff --git a/apps/sim/vercel.json b/apps/sim/vercel.json index 2db156776..68c5c8547 100644 --- a/apps/sim/vercel.json +++ b/apps/sim/vercel.json @@ -11,6 +11,10 @@ { "path": "/api/logs/cleanup", "schedule": "0 0 * * *" + }, + { + "path": "/api/billing/daily", + "schedule": "0 2 * * *" } ] } diff --git a/bun.lock b/bun.lock index 7f3600bb8..f5f0f8431 100644 --- a/bun.lock +++ b/bun.lock @@ -95,7 +95,6 @@ "@radix-ui/react-tooltip": "^1.1.6", "@react-email/components": "^0.0.34", "@sentry/nextjs": "^9.15.0", - "@types/js-yaml": "4.0.9", "@types/three": "0.177.0", "@vercel/og": "^0.6.5", "@vercel/speed-insights": "^1.2.0", @@ -153,6 +152,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/js-yaml": "4.0.9", "@types/jsdom": "21.1.7", "@types/lodash": "^4.17.16", "@types/node": "^22",