From 56543dafb4fbca3aa9f9868974faf7448b0c692d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 28 Aug 2025 17:00:48 -0700 Subject: [PATCH] fix(billing): usage tracking cleanup, shared pool of limits for team/enterprise (#1131) * fix(billing): team usage tracking cleanup, shared pool of limits for team * address greptile commments * fix lint * remove usage of deprecated cols" * update periodStart and periodEnd correctly * fix lint * fix type issue * fix(billing): cleaned up billing, still more work to do on UI and population of data and consolidation * fix upgrade * cleanup * progress * works * Remove 78th migration to prepare for merge with staging * fix migration conflict * remove useless test file * fix * Fix undefined seat pricing display and handle cancelled subscription seat updates * cleanup code * cleanup to use helpers for pulling pricing limits * cleanup more things * cleanup * restore environment ts file * remove unused files * fix(team-management): fix team management UI, consolidate components * use session data instead of subscription data in settings navigation * remove unused code * fix UI for enterprise plans * added enterprise plan support * progress * billing state machine * split overage and base into separate invoices * fix badge logic --------- Co-authored-by: waleedlatif1 --- apps/sim/app/api/auth/webhook/stripe/route.ts | 7 + apps/sim/app/api/billing/portal/route.ts | 77 + apps/sim/app/api/billing/route.ts | 28 +- apps/sim/app/api/billing/update-cost/route.ts | 72 +- .../app/api/billing/webhooks/stripe/route.ts | 116 - .../[id]/members/[memberId]/route.ts | 1 - .../api/organizations/[id]/members/route.ts | 1 - apps/sim/app/api/organizations/route.ts | 73 + apps/sim/app/api/usage-limits/route.ts | 117 +- apps/sim/app/api/usage/check/route.ts | 38 + .../components/control-bar/control-bar.tsx | 24 +- .../settings-navigation.tsx | 8 +- .../components/shared/usage-header.tsx | 119 + .../cancel-subscription.tsx | 171 +- .../components/usage-limit/usage-limit.tsx | 43 +- .../subscription/subscription-permissions.ts | 2 +- .../components/subscription/subscription.tsx | 209 +- .../team-management/components/index.ts | 18 +- .../member-invitation-card/index.ts | 1 - .../member-invitation-card.tsx | 294 +- .../components/member-limit/index.ts | 1 - .../components/member-limit/member-limit.tsx | 244 - .../components/no-organization-view/index.ts | 1 - .../no-organization-view.tsx | 155 +- .../organization-creation-dialog/index.ts | 1 - .../organization-creation-dialog.tsx | 100 - .../organization-settings-tab/index.ts | 1 - .../organization-settings-tab.tsx | 136 - .../pending-invitations-list/index.ts | 1 - .../pending-invitations-list.tsx | 53 - .../components/remove-member-dialog/index.ts | 1 - .../components/team-members-list/index.ts | 1 - .../team-members-list/team-members-list.tsx | 68 - .../components/team-members/team-members.tsx | 168 + .../components/team-seats-overview/index.ts | 1 - .../team-seats-overview.tsx | 198 +- .../components/team-seats/index.ts | 1 - .../components/team-seats/team-seats.tsx | 52 +- .../components/team-usage/index.ts | 1 - .../components/team-usage/team-usage.tsx | 395 +- .../team-management/team-management.tsx | 278 +- .../subscription-modal/subscription-modal.tsx | 42 +- .../usage-indicator/usage-indicator.tsx | 22 +- .../w/components/sidebar/sidebar.tsx | 26 +- .../db/migrations/0079_shocking_shriek.sql | 8 + .../sim/db/migrations/meta/0079_snapshot.json | 5818 +++++++++++++++++ apps/sim/db/migrations/meta/_journal.json | 7 + apps/sim/db/schema.ts | 11 +- apps/sim/hooks/use-subscription-state.ts | 12 - apps/sim/lib/auth-client.ts | 15 +- apps/sim/lib/auth.ts | 357 +- apps/sim/lib/billing/authorization.ts | 28 + .../lib/billing/calculations/usage-monitor.ts | 97 +- apps/sim/lib/billing/constants.ts | 9 +- .../lib/billing/core/billing-periods.test.ts | 175 - apps/sim/lib/billing/core/billing-periods.ts | 276 - apps/sim/lib/billing/core/billing.test.ts | 274 - apps/sim/lib/billing/core/billing.ts | 806 +-- .../lib/billing/core/organization-billing.ts | 148 +- apps/sim/lib/billing/core/subscription.ts | 134 +- apps/sim/lib/billing/core/usage.ts | 472 +- apps/sim/lib/billing/index.ts | 3 +- apps/sim/lib/billing/organization.ts | 184 + apps/sim/lib/billing/plans.ts | 65 + apps/sim/lib/billing/stripe-client.ts | 6 +- .../lib/billing/subscriptions/utils.test.ts | 28 +- apps/sim/lib/billing/subscriptions/utils.ts | 113 +- apps/sim/lib/billing/team-management.ts | 181 - apps/sim/lib/billing/types/index.ts | 23 +- .../lib/billing/validation/seat-management.ts | 9 +- apps/sim/lib/billing/webhooks/invoices.ts | 337 + .../webhooks/stripe-invoice-webhooks.ts | 202 - apps/sim/lib/env.ts | 1 - apps/sim/lib/logs/execution/logger.test.ts | 14 - apps/sim/lib/logs/execution/logger.ts | 84 +- apps/sim/lib/subscription/upgrade.ts | 175 + apps/sim/lib/workflows/utils.ts | 30 +- apps/sim/scripts/test-billing-suite.ts | 462 -- apps/sim/socket-server/middleware/auth.ts | 1 - apps/sim/stores/organization/store.ts | 84 +- apps/sim/stores/organization/types.ts | 10 +- apps/sim/stores/subscription/store.ts | 67 +- apps/sim/stores/subscription/types.ts | 13 +- bun.lock | 6 +- package.json | 2 + 85 files changed, 9033 insertions(+), 5080 deletions(-) create mode 100644 apps/sim/app/api/auth/webhook/stripe/route.ts create mode 100644 apps/sim/app/api/billing/portal/route.ts delete mode 100644 apps/sim/app/api/billing/webhooks/stripe/route.ts create mode 100644 apps/sim/app/api/organizations/route.ts create mode 100644 apps/sim/app/api/usage/check/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/member-limit.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog/organization-creation-dialog.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-settings-tab/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-settings-tab/organization-settings-tab.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/pending-invitations-list/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/pending-invitations-list/pending-invitations-list.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/remove-member-dialog/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list/team-members-list.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-usage/index.ts create mode 100644 apps/sim/db/migrations/0079_shocking_shriek.sql create mode 100644 apps/sim/db/migrations/meta/0079_snapshot.json create mode 100644 apps/sim/lib/billing/authorization.ts delete mode 100644 apps/sim/lib/billing/core/billing-periods.test.ts delete mode 100644 apps/sim/lib/billing/core/billing-periods.ts delete mode 100644 apps/sim/lib/billing/core/billing.test.ts create mode 100644 apps/sim/lib/billing/organization.ts create mode 100644 apps/sim/lib/billing/plans.ts delete mode 100644 apps/sim/lib/billing/team-management.ts create mode 100644 apps/sim/lib/billing/webhooks/invoices.ts delete mode 100644 apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts create mode 100644 apps/sim/lib/subscription/upgrade.ts delete mode 100644 apps/sim/scripts/test-billing-suite.ts diff --git a/apps/sim/app/api/auth/webhook/stripe/route.ts b/apps/sim/app/api/auth/webhook/stripe/route.ts new file mode 100644 index 000000000..7ced978b6 --- /dev/null +++ b/apps/sim/app/api/auth/webhook/stripe/route.ts @@ -0,0 +1,7 @@ +import { toNextJsHandler } from 'better-auth/next-js' +import { auth } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +// Handle Stripe webhooks through better-auth +export const { GET, POST } = toNextJsHandler(auth.handler) diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts new file mode 100644 index 000000000..838b4bfff --- /dev/null +++ b/apps/sim/app/api/billing/portal/route.ts @@ -0,0 +1,77 @@ +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { requireStripeClient } from '@/lib/billing/stripe-client' +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' +import { db } from '@/db' +import { subscription as subscriptionTable, user } from '@/db/schema' + +const logger = createLogger('BillingPortal') + +export async function POST(request: NextRequest) { + const session = await getSession() + + try { + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json().catch(() => ({})) + const context: 'user' | 'organization' = + body?.context === 'organization' ? 'organization' : 'user' + const organizationId: string | undefined = body?.organizationId || undefined + const returnUrl: string = + body?.returnUrl || `${env.NEXT_PUBLIC_APP_URL}/workspace?billing=updated` + + const stripe = requireStripeClient() + + let stripeCustomerId: string | null = null + + if (context === 'organization') { + if (!organizationId) { + return NextResponse.json({ error: 'organizationId is required' }, { status: 400 }) + } + + const rows = await db + .select({ customer: subscriptionTable.stripeCustomerId }) + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.referenceId, organizationId), + eq(subscriptionTable.status, 'active') + ) + ) + .limit(1) + + stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null + } else { + const rows = await db + .select({ customer: user.stripeCustomerId }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + + stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null + } + + if (!stripeCustomerId) { + logger.error('Stripe customer not found for portal session', { + context, + organizationId, + userId: session.user.id, + }) + return NextResponse.json({ error: 'Stripe customer not found' }, { status: 404 }) + } + + const portal = await stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: returnUrl, + }) + + return NextResponse.json({ url: portal.url }) + } catch (error) { + logger.error('Failed to create billing portal session', { error }) + return NextResponse.json({ error: 'Failed to create billing portal session' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts index 6769fee05..07776b4ea 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -5,7 +5,7 @@ import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing' import { getOrganizationBillingData } from '@/lib/billing/core/organization-billing' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' -import { member } from '@/db/schema' +import { member, userStats } from '@/db/schema' const logger = createLogger('UnifiedBillingAPI') @@ -45,6 +45,16 @@ export async function GET(request: NextRequest) { if (context === 'user') { // Get user billing (may include organization if they're part of one) billingData = await getSimplifiedBillingSummary(session.user.id, contextId || undefined) + // Attach billingBlocked status for the current user + const stats = await db + .select({ blocked: userStats.billingBlocked }) + .from(userStats) + .where(eq(userStats.userId, session.user.id)) + .limit(1) + billingData = { + ...billingData, + billingBlocked: stats.length > 0 ? !!stats[0].blocked : false, + } } else { // Get user role in organization for permission checks first const memberRecord = await db @@ -78,8 +88,10 @@ export async function GET(request: NextRequest) { subscriptionStatus: rawBillingData.subscriptionStatus, totalSeats: rawBillingData.totalSeats, usedSeats: rawBillingData.usedSeats, + seatsCount: rawBillingData.seatsCount, totalCurrentUsage: rawBillingData.totalCurrentUsage, totalUsageLimit: rawBillingData.totalUsageLimit, + minimumBillingAmount: rawBillingData.minimumBillingAmount, averageUsagePerMember: rawBillingData.averageUsagePerMember, billingPeriodStart: rawBillingData.billingPeriodStart?.toISOString() || null, billingPeriodEnd: rawBillingData.billingPeriodEnd?.toISOString() || null, @@ -92,11 +104,25 @@ export async function GET(request: NextRequest) { const userRole = memberRecord[0].role + // Include the requesting user's blocked flag as well so UI can reflect it + const stats = await db + .select({ blocked: userStats.billingBlocked }) + .from(userStats) + .where(eq(userStats.userId, session.user.id)) + .limit(1) + + // Merge blocked flag into data for convenience + billingData = { + ...billingData, + billingBlocked: stats.length > 0 ? !!stats[0].blocked : false, + } + return NextResponse.json({ success: true, context, data: billingData, userRole, + billingBlocked: billingData.billingBlocked, }) } diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 9cf6a0c11..27681bd12 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -115,52 +115,34 @@ export async function POST(req: NextRequest) { const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId)) if (userStatsRecords.length === 0) { - // Create new user stats record (same logic as ExecutionLogger) - await db.insert(userStats).values({ - id: crypto.randomUUID(), - userId: userId, - totalManualExecutions: 0, - totalApiCalls: 0, - totalWebhookTriggers: 0, - totalScheduledExecutions: 0, - totalChatExecutions: 0, - totalTokensUsed: totalTokens, - totalCost: costToStore.toString(), - currentPeriodCost: costToStore.toString(), - // Copilot usage tracking - totalCopilotCost: costToStore.toString(), - totalCopilotTokens: totalTokens, - totalCopilotCalls: 1, - lastActive: new Date(), - }) - - logger.info(`[${requestId}] Created new user stats record`, { - userId, - totalCost: costToStore, - totalTokens, - }) - } else { - // Update existing user stats record (same logic as ExecutionLogger) - const updateFields = { - totalTokensUsed: sql`total_tokens_used + ${totalTokens}`, - totalCost: sql`total_cost + ${costToStore}`, - currentPeriodCost: sql`current_period_cost + ${costToStore}`, - // Copilot usage tracking increments - totalCopilotCost: sql`total_copilot_cost + ${costToStore}`, - totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`, - totalCopilotCalls: sql`total_copilot_calls + 1`, - totalApiCalls: sql`total_api_calls`, - lastActive: new Date(), - } - - await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId)) - - logger.info(`[${requestId}] Updated user stats record`, { - userId, - addedCost: costToStore, - addedTokens: totalTokens, - }) + logger.error( + `[${requestId}] User stats record not found - should be created during onboarding`, + { + userId, + } + ) + return NextResponse.json({ error: 'User stats record not found' }, { status: 500 }) } + // Update existing user stats record (same logic as ExecutionLogger) + const updateFields = { + totalTokensUsed: sql`total_tokens_used + ${totalTokens}`, + totalCost: sql`total_cost + ${costToStore}`, + currentPeriodCost: sql`current_period_cost + ${costToStore}`, + // Copilot usage tracking increments + totalCopilotCost: sql`total_copilot_cost + ${costToStore}`, + totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`, + totalCopilotCalls: sql`total_copilot_calls + 1`, + totalApiCalls: sql`total_api_calls`, + lastActive: new Date(), + } + + await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId)) + + logger.info(`[${requestId}] Updated user stats record`, { + userId, + addedCost: costToStore, + addedTokens: totalTokens, + }) const duration = Date.now() - startTime diff --git a/apps/sim/app/api/billing/webhooks/stripe/route.ts b/apps/sim/app/api/billing/webhooks/stripe/route.ts deleted file mode 100644 index 2255bce8a..000000000 --- a/apps/sim/app/api/billing/webhooks/stripe/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { headers } from 'next/headers' -import { type NextRequest, NextResponse } from 'next/server' -import type Stripe from 'stripe' -import { requireStripeClient } from '@/lib/billing/stripe-client' -import { handleInvoiceWebhook } from '@/lib/billing/webhooks/stripe-invoice-webhooks' -import { env } from '@/lib/env' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('StripeInvoiceWebhook') - -/** - * Stripe billing webhook endpoint for invoice-related events - * Endpoint: /api/billing/webhooks/stripe - * Handles: invoice.payment_succeeded, invoice.payment_failed, invoice.finalized - */ -export async function POST(request: NextRequest) { - try { - const body = await request.text() - const headersList = await headers() - const signature = headersList.get('stripe-signature') - - if (!signature) { - logger.error('Missing Stripe signature header') - return NextResponse.json({ error: 'Missing Stripe signature' }, { status: 400 }) - } - - if (!env.STRIPE_BILLING_WEBHOOK_SECRET) { - logger.error('Missing Stripe webhook secret configuration') - return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 }) - } - - // Check if Stripe client is available - let stripe - try { - stripe = requireStripeClient() - } catch (stripeError) { - logger.error('Stripe client not available for webhook processing', { - error: stripeError, - }) - return NextResponse.json({ error: 'Stripe client not configured' }, { status: 500 }) - } - - // Verify webhook signature - let event: Stripe.Event - try { - event = stripe.webhooks.constructEvent(body, signature, env.STRIPE_BILLING_WEBHOOK_SECRET) - } catch (signatureError) { - logger.error('Invalid Stripe webhook signature', { - error: signatureError, - signature, - }) - return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }) - } - - logger.info('Received Stripe invoice webhook', { - eventId: event.id, - eventType: event.type, - }) - - // Handle specific invoice events - const supportedEvents = [ - 'invoice.payment_succeeded', - 'invoice.payment_failed', - 'invoice.finalized', - ] - - if (supportedEvents.includes(event.type)) { - try { - await handleInvoiceWebhook(event) - - logger.info('Successfully processed invoice webhook', { - eventId: event.id, - eventType: event.type, - }) - - return NextResponse.json({ received: true }) - } catch (processingError) { - logger.error('Failed to process invoice webhook', { - eventId: event.id, - eventType: event.type, - error: processingError, - }) - - // Return 500 to tell Stripe to retry the webhook - return NextResponse.json({ error: 'Failed to process webhook' }, { status: 500 }) - } - } else { - // Not a supported invoice event, ignore - logger.info('Ignoring unsupported webhook event', { - eventId: event.id, - eventType: event.type, - supportedEvents, - }) - - return NextResponse.json({ received: true }) - } - } catch (error) { - logger.error('Fatal error in invoice webhook handler', { - error, - url: request.url, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -/** - * GET endpoint for webhook health checks - */ -export async function GET() { - return NextResponse.json({ - status: 'healthy', - webhook: 'stripe-invoices', - events: ['invoice.payment_succeeded', 'invoice.payment_failed', 'invoice.finalized'], - }) -} diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 9053d7c68..7a26e29ae 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -81,7 +81,6 @@ export async function GET( .select({ currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, - usageLimitSetBy: userStats.usageLimitSetBy, usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, lastPeriodCost: userStats.lastPeriodCost, }) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 9ae87b15c..9dda509a8 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -75,7 +75,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ userEmail: user.email, currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, - usageLimitSetBy: userStats.usageLimitSetBy, usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, }) .from(member) diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts new file mode 100644 index 000000000..3983b2094 --- /dev/null +++ b/apps/sim/app/api/organizations/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createOrganizationForTeamPlan } from '@/lib/billing/organization' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('CreateTeamOrganization') + +export async function POST(request: Request) { + try { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized - no active session' }, { status: 401 }) + } + + const user = session.user + + // Parse request body for optional name and slug + let organizationName = user.name + let organizationSlug: string | undefined + + try { + const body = await request.json() + if (body.name && typeof body.name === 'string') { + organizationName = body.name + } + if (body.slug && typeof body.slug === 'string') { + organizationSlug = body.slug + } + } catch { + // If no body or invalid JSON, use defaults + } + + logger.info('Creating organization for team plan', { + userId: user.id, + userName: user.name, + userEmail: user.email, + organizationName, + organizationSlug, + }) + + // Create organization and make user the owner/admin + const organizationId = await createOrganizationForTeamPlan( + user.id, + organizationName || undefined, + user.email, + organizationSlug + ) + + logger.info('Successfully created organization for team plan', { + userId: user.id, + organizationId, + }) + + return NextResponse.json({ + success: true, + organizationId, + }) + } catch (error) { + logger.error('Failed to create organization for team plan', { + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined, + }) + + return NextResponse.json( + { + error: 'Failed to create organization', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/usage-limits/route.ts b/apps/sim/app/api/usage-limits/route.ts index 55178294a..5720324b1 100644 --- a/apps/sim/app/api/usage-limits/route.ts +++ b/apps/sim/app/api/usage-limits/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { getUserUsageLimitInfo, updateUserUsageLimit } from '@/lib/billing' -import { updateMemberUsageLimit } from '@/lib/billing/core/organization-billing' +import { getOrganizationBillingData } from '@/lib/billing/core/organization-billing' import { createLogger } from '@/lib/logs/console/logger' import { isOrganizationOwnerOrAdmin } from '@/lib/permissions/utils' @@ -9,7 +9,7 @@ const logger = createLogger('UnifiedUsageLimitsAPI') /** * Unified Usage Limits Endpoint - * GET/PUT /api/usage-limits?context=user|member&userId=&organizationId= + * GET/PUT /api/usage-limits?context=user|organization&userId=&organizationId= * */ export async function GET(request: NextRequest) { @@ -26,40 +26,13 @@ export async function GET(request: NextRequest) { const organizationId = searchParams.get('organizationId') // Validate context - if (!['user', 'member'].includes(context)) { + if (!['user', 'organization'].includes(context)) { return NextResponse.json( - { error: 'Invalid context. Must be "user" or "member"' }, + { error: 'Invalid context. Must be "user" or "organization"' }, { status: 400 } ) } - // For member context, require organizationId and check permissions - if (context === 'member') { - if (!organizationId) { - return NextResponse.json( - { error: 'Organization ID is required when context=member' }, - { status: 400 } - ) - } - - // Check if the current user has permission to view member usage info - const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId) - if (!hasPermission) { - logger.warn('Unauthorized attempt to view member usage info', { - requesterId: session.user.id, - targetUserId: userId, - organizationId, - }) - return NextResponse.json( - { - error: - 'Permission denied. Only organization owners and admins can view member usage information', - }, - { status: 403 } - ) - } - } - // For user context, ensure they can only view their own info if (context === 'user' && userId !== session.user.id) { return NextResponse.json( @@ -69,6 +42,23 @@ export async function GET(request: NextRequest) { } // Get usage limit info + if (context === 'organization') { + if (!organizationId) { + return NextResponse.json( + { error: 'Organization ID is required when context=organization' }, + { status: 400 } + ) + } + const org = await getOrganizationBillingData(organizationId) + return NextResponse.json({ + success: true, + context, + userId, + organizationId, + data: org, + }) + } + const usageLimitInfo = await getUserUsageLimitInfo(userId) return NextResponse.json({ @@ -96,12 +86,11 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { searchParams } = new URL(request.url) - const context = searchParams.get('context') || 'user' - const userId = searchParams.get('userId') || session.user.id - const organizationId = searchParams.get('organizationId') - - const { limit } = await request.json() + const body = await request.json() + const limit = body?.limit + const context = body?.context || 'user' + const organizationId = body?.organizationId + const userId = session.user.id if (typeof limit !== 'number' || limit < 0) { return NextResponse.json( @@ -110,52 +99,42 @@ export async function PUT(request: NextRequest) { ) } + if (!['user', 'organization'].includes(context)) { + return NextResponse.json( + { error: 'Invalid context. Must be "user" or "organization"' }, + { status: 400 } + ) + } + if (context === 'user') { // Update user's own usage limit - if (userId !== session.user.id) { - return NextResponse.json({ error: "Cannot update other users' limits" }, { status: 403 }) - } - await updateUserUsageLimit(userId, limit) - } else if (context === 'member') { - // Update organization member's usage limit + } else if (context === 'organization') { + // context === 'organization' if (!organizationId) { return NextResponse.json( - { error: 'Organization ID is required when context=member' }, + { error: 'Organization ID is required when context=organization' }, { status: 400 } ) } - // Check if the current user has permission to update member limits const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId) if (!hasPermission) { - logger.warn('Unauthorized attempt to update member usage limit', { - adminUserId: session.user.id, - targetUserId: userId, - organizationId, - }) - return NextResponse.json( - { - error: - 'Permission denied. Only organization owners and admins can update member usage limits', - }, - { status: 403 } - ) + return NextResponse.json({ error: 'Permission denied' }, { status: 403 }) } - logger.info('Authorized member usage limit update', { - adminUserId: session.user.id, - targetUserId: userId, - organizationId, - newLimit: limit, - }) - - await updateMemberUsageLimit(organizationId, userId, limit, session.user.id) - } else { - return NextResponse.json( - { error: 'Invalid context. Must be "user" or "member"' }, - { status: 400 } + // Use the dedicated function to update org usage limit + const { updateOrganizationUsageLimit } = await import( + '@/lib/billing/core/organization-billing' ) + const result = await updateOrganizationUsageLimit(organizationId, limit) + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } + + const updated = await getOrganizationBillingData(organizationId) + return NextResponse.json({ success: true, context, userId, organizationId, data: updated }) } // Return updated limit info diff --git a/apps/sim/app/api/usage/check/route.ts b/apps/sim/app/api/usage/check/route.ts new file mode 100644 index 000000000..e6bb2413a --- /dev/null +++ b/apps/sim/app/api/usage/check/route.ts @@ -0,0 +1,38 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { checkServerSideUsageLimits } from '@/lib/billing' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('UsageCheckAPI') + +export async function GET(_request: NextRequest) { + const session = await getSession() + try { + const userId = session?.user?.id + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const result = await checkServerSideUsageLimits(userId) + // Normalize to client usage shape + return NextResponse.json({ + success: true, + data: { + percentUsed: + result.limit > 0 + ? Math.min(Math.floor((result.currentUsage / result.limit) * 100), 100) + : 0, + isWarning: + result.limit > 0 + ? (result.currentUsage / result.limit) * 100 >= 80 && + (result.currentUsage / result.limit) * 100 < 100 + : false, + isExceeded: result.isExceeded, + currentUsage: result.currentUsage, + limit: result.limit, + message: result.message, + }, + }) + } catch (error) { + logger.error('Failed usage check', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} 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 4f324f5ca..b453c5413 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 @@ -320,23 +320,23 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { } try { - // Use subscription store to get usage data - const { getUsage, refresh } = useSubscriptionStore.getState() - - // Force refresh if requested - if (forceRefresh) { - await refresh() + // Primary: call server-side usage check to mirror backend enforcement + const res = await fetch('/api/usage/check', { cache: 'no-store' }) + if (res.ok) { + const payload = await res.json() + const usage = payload?.data + // Update cache + usageDataCache = { data: usage, timestamp: now, expirationMs: usageDataCache.expirationMs } + return usage } + // Fallback: use store if API not available + const { getUsage, refresh } = useSubscriptionStore.getState() + if (forceRefresh) await refresh() const usage = getUsage() // Update cache - usageDataCache = { - data: usage, - timestamp: now, - expirationMs: usageDataCache.expirationMs, - } - + usageDataCache = { data: usage, timestamp: now, expirationMs: usageDataCache.expirationMs } return usage } catch (error) { logger.error('Error checking usage limits:', { error }) 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 1100f9061..6c8e6182b 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 @@ -12,7 +12,6 @@ import { import { getEnv, isTruthy } from '@/lib/env' import { isHosted } from '@/lib/environment' import { cn } from '@/lib/utils' -import { useSubscriptionStore } from '@/stores/subscription/store' const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) @@ -106,9 +105,6 @@ export function SettingsNavigation({ onSectionChange, hasOrganization, }: SettingsNavigationProps) { - const { getSubscriptionStatus } = useSubscriptionStore() - const subscription = getSubscriptionStatus() - const navigationItems = allNavigationItems.filter((item) => { if (item.id === 'copilot' && !isHosted) { return false @@ -117,8 +113,8 @@ export function SettingsNavigation({ return false } - // Hide team tab if user doesn't have team or enterprise subscription - if (item.requiresTeam && !subscription.isTeam && !subscription.isEnterprise) { + // Hide team tab if user doesn't have an active organization + if (item.requiresTeam && !hasOrganization) { return false } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header.tsx new file mode 100644 index 000000000..b11c60125 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header.tsx @@ -0,0 +1,119 @@ +'use client' + +import type { ReactNode } from 'react' +import { Badge, Progress } from '@/components/ui' +import { cn } from '@/lib/utils' + +const GRADIENT_BADGE_STYLES = + 'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer' + +interface UsageHeaderProps { + title: string + gradientTitle?: boolean + showBadge?: boolean + badgeText?: string + onBadgeClick?: () => void + rightContent?: ReactNode + current: number + limit: number + progressValue?: number + seatsText?: string + isBlocked?: boolean + onResolvePayment?: () => void + status?: 'ok' | 'warning' | 'exceeded' | 'blocked' + percentUsed?: number +} + +export function UsageHeader({ + title, + gradientTitle = false, + showBadge = false, + badgeText, + onBadgeClick, + rightContent, + current, + limit, + progressValue, + seatsText, + isBlocked, + onResolvePayment, + status, + percentUsed, +}: UsageHeaderProps) { + const progress = progressValue ?? (limit > 0 ? Math.min((current / limit) * 100, 100) : 0) + + return ( +
+
+
+
+ + {title} + + {showBadge && badgeText ? ( + + {badgeText} + + ) : null} + {seatsText ? ( + ({seatsText}) + ) : null} +
+
+ {isBlocked ? ( + Payment required + ) : ( + <> + ${current.toFixed(2)} + / + {rightContent ?? ${limit}} + + )} +
+
+ + + + {isBlocked && ( +
+ + Payment failed. Please update your payment method. + + {onResolvePayment && ( + + )} +
+ )} + + {!isBlocked && status === 'exceeded' && ( +
+ + Usage limit exceeded. Increase your limit to continue. + +
+ )} + + {!isBlocked && status === 'warning' && ( +
+ + {typeof percentUsed === 'number' ? `${percentUsed}%` : '80%+'} of your limit used. + +
+ )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx index ee24a0264..18565e9c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx @@ -12,6 +12,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useSession, useSubscription } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' @@ -38,8 +39,9 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub const { data: session } = useSession() const betterAuthSubscription = useSubscription() - const { activeOrganization } = useOrganizationStore() - const { getSubscriptionStatus } = useSubscriptionStore() + const { activeOrganization, loadOrganizationSubscription, refreshOrganization } = + useOrganizationStore() + const { getSubscriptionStatus, refresh } = useSubscriptionStore() // Clear error after 3 seconds useEffect(() => { @@ -67,27 +69,43 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub const activeOrgId = activeOrganization?.id let referenceId = session.user.id + let subscriptionId: string | undefined + if (subscriptionStatus.isTeam && activeOrgId) { referenceId = activeOrgId + // Get subscription ID for team/enterprise + const orgSubscription = useOrganizationStore.getState().subscriptionData + subscriptionId = orgSubscription?.id } logger.info('Canceling subscription', { referenceId, + subscriptionId, isTeam: subscriptionStatus.isTeam, activeOrgId, }) - const result = await betterAuthSubscription.cancel({ - returnUrl: window.location.href, + if (!betterAuthSubscription.cancel) { + throw new Error('Subscription management not available') + } + + const returnUrl = window.location.origin + window.location.pathname.split('/w/')[0] + + const cancelParams: any = { + returnUrl, referenceId, - }) + } + + if (subscriptionId) { + cancelParams.subscriptionId = subscriptionId + } + + const result = await betterAuthSubscription.cancel(cancelParams) if (result && 'error' in result && result.error) { setError(result.error.message || 'Failed to cancel subscription') logger.error('Failed to cancel subscription via Better Auth', { error: result.error }) } else { - // Better Auth cancel redirects to Stripe Billing Portal - // So if we reach here without error, the redirect should happen logger.info('Redirecting to Stripe Billing Portal for cancellation') } } catch (error) { @@ -98,6 +116,49 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub setIsLoading(false) } } + const handleKeep = async () => { + if (!session?.user?.id) return + + setIsLoading(true) + setError(null) + + try { + const subscriptionStatus = getSubscriptionStatus() + const activeOrgId = activeOrganization?.id + + // For team/enterprise plans, get the subscription ID from organization store + if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) { + const orgSubscription = useOrganizationStore.getState().subscriptionData + + if (orgSubscription?.id && orgSubscription?.cancelAtPeriodEnd) { + // Restore the organization subscription + if (!betterAuthSubscription.restore) { + throw new Error('Subscription restore not available') + } + + const result = await betterAuthSubscription.restore({ + referenceId: activeOrgId, + subscriptionId: orgSubscription.id, + }) + logger.info('Organization subscription restored successfully', result) + } + } + + // Refresh state and close + await refresh() + if (activeOrgId) { + await loadOrganizationSubscription(activeOrgId) + await refreshOrganization().catch(() => {}) + } + setIsDialogOpen(false) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to keep subscription' + setError(errorMessage) + logger.error('Failed to keep subscription', { error }) + } finally { + setIsLoading(false) + } + } const getPeriodEndDate = () => { return subscriptionData?.periodEnd || null } @@ -127,14 +188,25 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub const periodEndDate = getPeriodEndDate() + // Check if subscription is set to cancel at period end + const isCancelAtPeriodEnd = (() => { + const subscriptionStatus = getSubscriptionStatus() + if (subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) { + return useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd === true + } + return false + })() + return ( <>
Manage Subscription -

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

+ {isCancelAtPeriodEnd && ( +

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

+ )}
- -
- - {showWorkspaceInvite && ( -
-
-
-
Workspace Access
- - Optional - -
- {selectedCount > 0 && ( - {selectedCount} selected - )} +
+ {emailError &&

{emailError}

}
-

- 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 - ) + {showWorkspaceInvite && ( +
+
+
+
Workspace Access
+ + Optional + +
+ {selectedCount > 0 && ( + {selectedCount} selected + )} +
+

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

- return ( -
+ {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 ( +
+
@@ -222,42 +253,43 @@ export function MemberInvitationCard({ )}
+
+ {/* Always reserve space for permission selector to maintain consistent layout */} +
{isSelected && ( -
- onWorkspaceToggle(workspace.id, permission)} - disabled={isInviting} - className='h-8' - /> -
+ onWorkspaceToggle(workspace.id, permission)} + disabled={isInviting} + className='w-auto' + /> )}
- ) - })} -
- )} -
- )} +
+ ) + })} +
+ )} +
+ )} - {inviteSuccess && ( - - - - Invitation sent successfully - {selectedCount > 0 && - ` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`} - - - )} - - + {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/member-limit/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/index.ts deleted file mode 100644 index f09d182a3..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MemberLimit } from './member-limit' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/member-limit.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/member-limit.tsx deleted file mode 100644 index d78038fc6..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/member-limit.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { useEffect, useState } from 'react' -import { AlertTriangle, DollarSign, User } from 'lucide-react' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' - -interface MemberLimitProps { - open: boolean - onOpenChange: (open: boolean) => void - member: { - userId: string - userName: string - userEmail: string - currentUsage: number - usageLimit: number - role: string - } | null - onSave: (userId: string, newLimit: number) => Promise - isLoading: boolean - planType?: string -} - -export function MemberLimit({ - open, - onOpenChange, - member, - onSave, - isLoading, - planType = 'team', -}: MemberLimitProps) { - 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) || 0})` - ) - return - } - - try { - setError(null) - await onSave(member.userId, newLimit) - onOpenChange(false) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update limit') - } - } - - const formatCurrency = (amount: number) => `$${amount.toFixed(2)}` - - if (!member) return null - - const newLimit = Number.parseFloat(limitValue) || 0 - const isIncrease = newLimit > member.usageLimit - const isDecrease = newLimit < member.usageLimit - const limitDifference = Math.abs(newLimit - member.usageLimit) - - return ( - - - - - - 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()} - autoComplete='off' - data-form-type='other' - name='member-usage-limit' - /> -
-

- 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/team-management/components/no-organization-view/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/index.ts deleted file mode 100644 index 2d540c4f7..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { NoOrganizationView } from './no-organization-view' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx index 614b25b63..5c25c61ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -1,8 +1,15 @@ import { RefreshCw } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Input } from '@/components/ui/input' -import { OrganizationCreationDialog } from '../organization-creation-dialog' +import { Label } from '@/components/ui/label' interface NoOrganizationViewProps { hasTeamPlan: boolean @@ -35,44 +42,47 @@ export function NoOrganizationView({ }: NoOrganizationViewProps) { if (hasTeamPlan || hasEnterprisePlan) { return ( -
-
-

Create Your Team Workspace

- -
-

+

+
+ {/* Header - matching settings page style */} +
+

Create Your Team Workspace

+

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

+
-
-
- - -
+ {/* Form fields - clean layout without card */} +
+
+ + +
-
- -
-
- sim.ai/team/ -
- setOrgSlug(e.target.value)} - className='rounded-l-none' - /> +
+ +
+
+ sim.ai/team/
+ setOrgSlug(e.target.value)} + placeholder='my-team' + className='rounded-l-none' + />
@@ -83,7 +93,7 @@ export function NoOrganizationView({ )} -
+
- + + + + Create Team Organization + + Create a new team organization to manage members and billing. + + + +
+ {error && ( + + Error + {error} + + )} + +
+ + +
+ +
+ + setOrgSlug(e.target.value)} + disabled={isCreatingOrg} + className='mt-1' + /> +
+ +
+ + +
+
+
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog/index.ts deleted file mode 100644 index 6377972fa..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OrganizationCreationDialog } from './organization-creation-dialog' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog/organization-creation-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog/organization-creation-dialog.tsx deleted file mode 100644 index 976f252dc..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog/organization-creation-dialog.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { RefreshCw } from 'lucide-react' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' - -interface OrganizationCreationDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - orgName: string - onOrgNameChange: (e: React.ChangeEvent) => 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. - - - -
-
- - -
- -
- -
-
- sim.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/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-settings-tab/index.ts deleted file mode 100644 index 5c4f4fb5e..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-settings-tab/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OrganizationSettingsTab } from './organization-settings-tab' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-settings-tab/organization-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-settings-tab/organization-settings-tab.tsx deleted file mode 100644 index 46d950775..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-settings-tab/organization-settings-tab.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import type { Organization, OrganizationFormData } from '@/stores/organization' - -interface OrganizationSettingsTabProps { - organization: Organization - isAdminOrOwner: boolean - userRole: string - orgFormData: OrganizationFormData - onOrgInputChange: (field: string, value: string) => void - onSaveOrgSettings: () => Promise - 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/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/pending-invitations-list/index.ts deleted file mode 100644 index 213a663f8..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/pending-invitations-list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PendingInvitationsList } from './pending-invitations-list' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/pending-invitations-list/pending-invitations-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/pending-invitations-list/pending-invitations-list.tsx deleted file mode 100644 index f183cd22d..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/pending-invitations-list/pending-invitations-list.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { X } from 'lucide-react' -import { Button } from '@/components/ui/button' -import type { Invitation, Organization } from '@/stores/organization' - -interface PendingInvitationsListProps { - organization: Organization - onCancelInvitation: (invitationId: string) => void -} - -export function PendingInvitationsList({ - organization, - onCancelInvitation, -}: PendingInvitationsListProps) { - const pendingInvitations = organization.invitations?.filter( - (invitation) => invitation.status === 'pending' - ) - - if (!pendingInvitations || pendingInvitations.length === 0) { - return null - } - - return ( -
-

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/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/remove-member-dialog/index.ts deleted file mode 100644 index 2eed30381..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/remove-member-dialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { RemoveMemberDialog } from './remove-member-dialog' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list/index.ts deleted file mode 100644 index 8b0fb5206..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TeamMembersList } from './team-members-list' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list/team-members-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list/team-members-list.tsx deleted file mode 100644 index 7c79e6eb8..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list/team-members-list.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { UserX } from 'lucide-react' -import { Button } from '@/components/ui/button' -import type { Member, Organization } from '@/stores/organization' - -interface TeamMembersListProps { - organization: Organization - currentUserEmail: string - isAdminOrOwner: boolean - onRemoveMember: (member: Member) => void -} - -export function TeamMembersList({ - organization, - currentUserEmail, - isAdminOrOwner, - onRemoveMember, -}: TeamMembersListProps) { - if (!organization.members || organization.members.length === 0) { - return ( -
-

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-members/team-members.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx new file mode 100644 index 000000000..6e4eddfbc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx @@ -0,0 +1,168 @@ +import { UserX, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { Invitation, Member, Organization } from '@/stores/organization' + +interface ConsolidatedTeamMembersProps { + organization: Organization + currentUserEmail: string + isAdminOrOwner: boolean + onRemoveMember: (member: Member) => void + onCancelInvitation: (invitationId: string) => void +} + +interface TeamMemberItem { + type: 'member' | 'invitation' + id: string + name: string + email: string + role: string + usage?: string + lastActive?: string + member?: Member + invitation?: Invitation +} + +export function TeamMembers({ + organization, + currentUserEmail, + isAdminOrOwner, + onRemoveMember, + onCancelInvitation, +}: ConsolidatedTeamMembersProps) { + // Combine members and pending invitations into a single list + const teamItems: TeamMemberItem[] = [] + + // Add existing members + if (organization.members) { + organization.members.forEach((member: Member) => { + teamItems.push({ + type: 'member', + id: member.id, + name: member.user?.name || 'Unknown', + email: member.user?.email || '', + role: member.role, + usage: '$0.00', // TODO: Get real usage data + lastActive: '8/26/2025', // TODO: Get real last active date + member, + }) + }) + } + + // Add pending invitations + const pendingInvitations = organization.invitations?.filter( + (invitation) => invitation.status === 'pending' + ) + if (pendingInvitations) { + pendingInvitations.forEach((invitation: Invitation) => { + teamItems.push({ + type: 'invitation', + id: invitation.id, + name: invitation.email.split('@')[0], // Use email prefix as name + email: invitation.email, + role: 'pending', + usage: '-', + lastActive: '-', + invitation, + }) + }) + } + + if (teamItems.length === 0) { + return
No team members yet.
+ } + + return ( +
+ {/* Header - simple like account page */} +
+

Team Members

+
+ + {/* Members list - clean like account page */} +
+ {teamItems.map((item) => ( +
+ {/* Member info */} +
+ {/* Avatar */} +
+ {item.name.charAt(0).toUpperCase()} +
+ + {/* Name and email */} +
+
+ {item.name} + {item.type === 'member' && ( + + {item.role.charAt(0).toUpperCase() + item.role.slice(1)} + + )} + {item.type === 'invitation' && ( + + Pending + + )} +
+
{item.email}
+
+ + {/* Usage and stats - matching subscription layout */} +
+
+
Usage
+
{item.usage}
+
+
+
Active
+
{item.lastActive}
+
+
+
+ + {/* Actions */} + {isAdminOrOwner && ( +
+ {item.type === 'member' && + item.member?.role !== 'owner' && + item.email !== currentUserEmail && ( + + )} + + {item.type === 'invitation' && ( + + )} +
+ )} +
+ ))} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/index.ts deleted file mode 100644 index c8f65cc4d..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TeamSeatsOverview } from './team-seats-overview' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx index bea33520e..1be9ceaca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx @@ -1,9 +1,10 @@ import { Building2 } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' +import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants' import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils' +import { env } from '@/lib/env' type Subscription = { id: string @@ -29,9 +30,25 @@ interface TeamSeatsOverviewProps { function TeamSeatsSkeleton() { return ( -
- - +
+
+
+
+ + +
+
+ + / + +
+
+ +
+ + +
+
) } @@ -46,123 +63,86 @@ export function TeamSeatsOverview({ onAddSeatDialog, }: TeamSeatsOverviewProps) { if (isLoadingSubscription) { - return ( - - - Team Seats Overview - Manage your team's seat allocation and billing - - - - - - ) + return } 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. -

-
- +
+
+
+
- - +
+

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)} +
+
+ {/* Seats info and usage - matching team usage layout */} +
+
+ Seats + + (${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
-
- Individual usage limits may vary. See Subscription tab for team totals. +
+ {usedSeats} used + / + {subscriptionData.seats || 0} total
- - {checkEnterprisePlan(subscriptionData) ? ( -
-

Enterprise Plan

-

Contact support to modify seats

-
- ) : ( -
- - -
- )}
- - + + {/* Progress Bar - matching team usage component */} + + + {/* Action buttons - below the usage display */} + {checkEnterprisePlan(subscriptionData) ? ( +
+

+ Contact enterprise for support usage limit changes +

+
+ ) : ( +
+ + +
+ )} +
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/index.ts deleted file mode 100644 index 5a45773dc..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TeamSeats } from './team-seats' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx index 231944ce6..96cef39c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx @@ -16,6 +16,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants' import { env } from '@/lib/env' interface TeamSeatsProps { @@ -29,6 +31,7 @@ interface TeamSeatsProps { onConfirm: (seats: number) => Promise confirmButtonText: string showCostBreakdown?: boolean + isCancelledAtPeriodEnd?: boolean } export function TeamSeats({ @@ -42,6 +45,7 @@ export function TeamSeats({ onConfirm, confirmButtonText, showCostBreakdown = false, + isCancelledAtPeriodEnd = false, }: TeamSeatsProps) { const [selectedSeats, setSelectedSeats] = useState(initialSeats) @@ -51,7 +55,7 @@ export function TeamSeats({ } }, [open, initialSeats]) - const costPerSeat = env.TEAM_TIER_COST_LIMIT ?? 40 + const costPerSeat = env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT const totalMonthlyCost = selectedSeats * costPerSeat const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0 @@ -114,19 +118,39 @@ export function TeamSeats({ - + + + + + + + + {isCancelledAtPeriodEnd && ( + +

+ To update seats, go to Subscription {'>'} Manage {'>'} Keep Subscription to + reactivate +

+
+ )} +
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-usage/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-usage/index.ts deleted file mode 100644 index 635cd8a1c..000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-usage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TeamUsage } from './team-usage' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-usage/team-usage.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-usage/team-usage.tsx index 79514cec8..432e53b42 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-usage/team-usage.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-usage/team-usage.tsx @@ -1,15 +1,16 @@ -import { useEffect, useState } from 'react' -import { AlertCircle, Settings2 } from 'lucide-react' +import { useCallback, useEffect, useRef } from 'react' +import { AlertCircle } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { useActiveOrganization } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' +import { UsageHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header' +import { + UsageLimit, + type UsageLimitRef, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components' import { useOrganizationStore } from '@/stores/organization' -import type { MemberUsageData } from '@/stores/organization/types' -import { MemberLimit } from '../member-limit' +import { useSubscriptionStore } from '@/stores/subscription/store' const logger = createLogger('TeamUsage') @@ -19,14 +20,11 @@ interface TeamUsageProps { export function TeamUsage({ hasAdminAccess }: TeamUsageProps) { const { data: activeOrg } = useActiveOrganization() - const [editDialogOpen, setEditDialogOpen] = useState(false) - const [selectedMember, setSelectedMember] = useState(null) - const [isUpdating, setIsUpdating] = useState(false) + const { getSubscriptionStatus } = useSubscriptionStore() const { organizationBillingData: billingData, loadOrganizationBillingData, - updateMemberUsageLimit, isLoadingOrgBilling, error, } = useOrganizationStore() @@ -37,143 +35,35 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) { } }, [activeOrg?.id, loadOrganizationBillingData]) - const handleEditLimit = (member: MemberUsageData) => { - setSelectedMember(member) - setEditDialogOpen(true) - } - - const handleSaveLimit = async (userId: string, newLimit: number): Promise => { - if (!activeOrg?.id) { - throw new Error('No active organization found') - } - - try { - setIsUpdating(true) - const result = await updateMemberUsageLimit(userId, activeOrg.id, newLimit) - - if (!result.success) { - logger.error('Failed to update usage limit', { error: result.error, userId, newLimit }) - throw new Error(result.error || 'Failed to update usage limit') + const handleLimitUpdated = useCallback( + async (newLimit: number) => { + // Reload the organization billing data to reflect the new limit + if (activeOrg?.id) { + await loadOrganizationBillingData(activeOrg.id, true) } + }, + [activeOrg?.id, loadOrganizationBillingData] + ) - logger.info('Successfully updated member usage limit', { - userId, - newLimit, - organizationId: activeOrg.id, - }) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to update usage limit' - logger.error('Failed to update usage limit', { - error, - userId, - newLimit, - organizationId: activeOrg.id, - }) - throw new Error(errorMessage) - } finally { - setIsUpdating(false) - } - } - - const handleCloseEditDialog = () => { - setEditDialogOpen(false) - setSelectedMember(null) - } - - const formatCurrency = (amount: number) => `$${amount.toFixed(2)}` - const formatDate = (dateString: string | null) => { - if (!dateString) return 'Never' - return new Date(dateString).toLocaleDateString() - } + const usageLimitRef = useRef(null) 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 */} -
- -
-
-
- ))} -
+
+
+
+
+ +
- - +
+ + / + +
+
+ +
) } @@ -189,160 +79,79 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) { } if (!billingData) { - return ( - - - No Data - No billing data available for this organization. - - ) + return null } - const membersOverLimit = billingData.members?.filter((m) => m.isOverLimit).length || 0 - const membersNearLimit = - billingData.members?.filter((m) => !m.isOverLimit && m.percentUsed >= 80).length || 0 + const currentUsage = billingData.totalCurrentUsage || 0 + const currentCap = billingData.totalUsageLimit || billingData.minimumBillingAmount || 0 + const minimumBilling = billingData.minimumBillingAmount || 0 + const seatsCount = billingData.seatsCount || 1 + const percentUsed = + currentCap > 0 ? Math.round(Math.min((currentUsage / currentCap) * 100, 100)) : 0 + const status: 'ok' | 'warning' | 'exceeded' = + percentUsed >= 100 ? 'exceeded' : percentUsed >= 80 ? 'warning' : 'ok' + + const subscription = getSubscriptionStatus() + const title = subscription.isEnterprise + ? 'Enterprise' + : subscription.isTeam + ? 'Team' + : (subscription.plan || 'Free').charAt(0).toUpperCase() + + (subscription.plan || 'Free').slice(1) return ( -
- {/* 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 */} - -
+ { + if (!subscription.isEnterprise) usageLimitRef.current?.startEdit() + }} + seatsText={`${seatsCount} seats`} + current={currentUsage} + limit={currentCap} + isBlocked={Boolean(billingData?.billingBlocked)} + status={status} + percentUsed={percentUsed} + onResolvePayment={async () => { + try { + const res = await fetch('/api/billing/portal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + context: 'organization', + organizationId: activeOrg?.id, + returnUrl: `${window.location.origin}/workspace?billing=updated`, + }), + }) + const data = await res.json() + if (!res.ok || !data?.url) + throw new Error(data?.error || 'Failed to start billing portal') + window.location.href = data.url + } catch (e) { + alert(e instanceof Error ? e.message : 'Failed to open billing portal') + } + }} + rightContent={ + hasAdminAccess && activeOrg?.id && !subscription.isEnterprise ? ( + + ) : ( + + ${currentCap.toFixed(0)} + + ) + } + progressValue={percentUsed} + /> ) } 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 72f5b75c2..d8cb575d8 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,31 +1,21 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { - Alert, - AlertDescription, - AlertTitle, - Skeleton, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from '@/components/ui' +import { Alert, AlertDescription, AlertTitle, Skeleton } from '@/components/ui' import { useSession } from '@/lib/auth-client' +import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants' import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' -import { generateSlug, useOrganizationStore } from '@/stores/organization' -import { useSubscriptionStore } from '@/stores/subscription/store' import { MemberInvitationCard, NoOrganizationView, - OrganizationSettingsTab, - PendingInvitationsList, RemoveMemberDialog, - TeamMembersList, + TeamMembers, TeamSeats, TeamSeatsOverview, TeamUsage, -} from './components' +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components' +import { generateSlug, useOrganizationStore } from '@/stores/organization' +import { useSubscriptionStore } from '@/stores/subscription/store' const logger = createLogger('TeamManagement') @@ -37,18 +27,14 @@ export function TeamManagement() { activeOrganization, subscriptionData, userWorkspaces, - orgFormData, hasTeamPlan, hasEnterprisePlan, isLoading, isLoadingSubscription, isCreatingOrg, isInviting, - isSavingOrgSettings, error, - orgSettingsError, inviteSuccess, - orgSettingsSuccess, loadData, createOrganization, setActiveOrganization, @@ -57,12 +43,10 @@ export function TeamManagement() { cancelInvitation, addSeats, reduceSeats, - updateOrganizationSettings, loadUserWorkspaces, getUserRole, isAdminOrOwner, getUsedSeats, - setOrgFormData, } = useOrganizationStore() const { getSubscriptionStatus } = useSubscriptionStore() @@ -81,7 +65,6 @@ export function TeamManagement() { }>({ open: false, memberId: '', memberName: '', shouldReduceSeats: false }) const [orgName, setOrgName] = useState('') const [orgSlug, setOrgSlug] = useState('') - const [activeTab, setActiveTab] = useState('members') const [isAddSeatDialogOpen, setIsAddSeatDialogOpen] = useState(false) const [newSeatCount, setNewSeatCount] = useState(1) const [isUpdatingSeats, setIsUpdatingSeats] = useState(false) @@ -99,7 +82,6 @@ export function TeamManagement() { } }, []) - // Set default organization name for team/enterprise users useEffect(() => { if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) { const defaultName = `${session.user.name}'s Team` @@ -108,7 +90,6 @@ export function TeamManagement() { } }, [hasTeamPlan, hasEnterprisePlan, session?.user?.name, orgName]) - // Load workspaces for admin users const activeOrgId = activeOrganization?.id useEffect(() => { if (session?.user?.id && activeOrgId && adminOrOwner) { @@ -124,11 +105,39 @@ export function TeamManagement() { const handleCreateOrganization = useCallback(async () => { if (!session?.user || !orgName.trim()) return - await createOrganization(orgName.trim(), orgSlug.trim()) - setCreateOrgDialogOpen(false) - setOrgName('') - setOrgSlug('') - }, [session?.user?.id, orgName, orgSlug]) + + try { + const response = await fetch('/api/organizations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: orgName.trim(), + slug: orgSlug.trim(), + }), + }) + + if (!response.ok) { + throw new Error(`Failed to create organization: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.success || !result.organizationId) { + throw new Error('Failed to create organization') + } + + // Refresh organization data + await loadData() + + setCreateOrgDialogOpen(false) + setOrgName('') + setOrgSlug('') + } catch (error) { + logger.error('Failed to create organization', error) + } + }, [session?.user?.id, orgName, orgSlug, loadData]) const handleInviteMember = useCallback(async () => { if (!session?.user || !activeOrgId || !inviteEmail.trim()) return @@ -221,15 +230,6 @@ export function TeamManagement() { [subscriptionData?.id, activeOrgId, newSeatCount] ) - const handleOrgInputChange = useCallback((field: string, value: string) => { - setOrgFormData({ [field]: value }) - }, []) - - const handleSaveOrgSettings = useCallback(async () => { - if (!activeOrgId || !adminOrOwner) return - await updateOrganizationSettings() - }, [activeOrgId, adminOrOwner]) - const confirmTeamUpgrade = useCallback( async (seats: number) => { if (!session?.user || !activeOrgId) return @@ -241,10 +241,12 @@ export function TeamManagement() { if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) { return ( -
- - - +
+
+ + + +
) } @@ -269,104 +271,105 @@ export function TeamManagement() { } return ( -
-
-

Team Management

+
+
+ {error && ( + + Error + {error} + + )} - {organizations.length > 1 && ( -
- + {/* Team Usage Overview */} + + + {/* Team Billing Information (only show for Team Plan, not Enterprise) */} + {hasTeamPlan && !hasEnterprisePlan && ( +
+
+

How Team Billing Works

+
    +
  • + Your team is billed a minimum of $ + {(subscriptionData?.seats || 0) * + (env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT)} + /month for {subscriptionData?.seats || 0} licensed seats +
  • +
  • All team member usage is pooled together from a shared limit
  • +
  • + When pooled usage exceeds the limit, all members are blocked from using the + service +
  • +
  • You can increase the usage limit to allow for higher usage
  • +
  • + Any usage beyond the minimum seat cost is billed as overage at the end of the + billing period +
  • +
+
)} + + {/* Member Invitation Card */} + {adminOrOwner && ( + loadUserWorkspaces(session?.user?.id)} + onWorkspaceToggle={handleWorkspaceToggle} + inviteSuccess={inviteSuccess} + availableSeats={Math.max(0, (subscriptionData?.seats || 0) - usedSeats.used)} + maxSeats={subscriptionData?.seats || 0} + /> + )} + + {/* Team Seats Overview */} + {adminOrOwner && ( + + )} + + {/* Team Members */} + + + {/* Team Information Section - at bottom of modal */} +
+
+
+ Team ID: + {activeOrganization.id} +
+
+ Created: + {new Date(activeOrganization.createdAt).toLocaleDateString()} +
+
+ Your Role: + {userRole} +
+
+
- {error && ( - - Error - {error} - - )} - - - - Members - Usage - Settings - - - - {adminOrOwner && ( - loadUserWorkspaces(session?.user?.id)} - onWorkspaceToggle={handleWorkspaceToggle} - inviteSuccess={inviteSuccess} - /> - )} - - {adminOrOwner && ( - - )} - - - - {adminOrOwner && (activeOrganization.invitations?.length ?? 0) > 0 && ( - - )} - - - - - - - - - - -
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/subscription-modal/subscription-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/subscription-modal/subscription-modal.tsx index f81f8359a..40ff7b818 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/subscription-modal/subscription-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/subscription-modal/subscription-modal.tsx @@ -22,8 +22,9 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' -import { useSession, useSubscription } from '@/lib/auth-client' +import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' +import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade' import { cn } from '@/lib/utils' import { useOrganizationStore } from '@/stores/organization' import { useSubscriptionStore } from '@/stores/subscription/store' @@ -43,7 +44,7 @@ interface PlanFeature { export function SubscriptionModal({ open, onOpenChange }: SubscriptionModalProps) { const { data: session } = useSession() - const betterAuthSubscription = useSubscription() + const { handleUpgrade } = useSubscriptionUpgrade() const { activeOrganization } = useOrganizationStore() const { loadData, getSubscriptionStatus, isLoading } = useSubscriptionStore() @@ -56,40 +57,15 @@ export function SubscriptionModal({ open, onOpenChange }: SubscriptionModalProps const subscription = getSubscriptionStatus() - const handleUpgrade = useCallback( + const handleUpgradeWithErrorHandling = useCallback( async (targetPlan: 'pro' | 'team') => { - if (!session?.user?.id) return - - const subscriptionData = useSubscriptionStore.getState().subscriptionData - const currentSubscriptionId = subscriptionData?.stripeSubscriptionId - - let referenceId = session.user.id - if (subscription.isTeam && activeOrganization?.id) { - referenceId = activeOrganization.id - } - - const currentUrl = window.location.origin + window.location.pathname - try { - const upgradeParams: any = { - plan: targetPlan, - referenceId, - successUrl: currentUrl, - cancelUrl: currentUrl, - seats: targetPlan === 'team' ? 1 : undefined, - } - - if (currentSubscriptionId) { - upgradeParams.subscriptionId = currentSubscriptionId - } - - await betterAuthSubscription.upgrade(upgradeParams) + await handleUpgrade(targetPlan) } catch (error) { - logger.error('Failed to initiate subscription upgrade:', error) - alert('Failed to initiate upgrade. Please try again or contact support.') + alert(error instanceof Error ? error.message : 'Unknown error occurred') } }, - [session?.user?.id, subscription.isTeam, activeOrganization?.id, betterAuthSubscription] + [handleUpgrade] ) const handleContactUs = () => { @@ -124,7 +100,7 @@ export function SubscriptionModal({ open, onOpenChange }: SubscriptionModalProps { text: 'Unlimited log retention', included: true, icon: Database }, ], isActive: subscription.isPro && !subscription.isTeam, - action: subscription.isFree ? () => handleUpgrade('pro') : null, + action: subscription.isFree ? () => handleUpgradeWithErrorHandling('pro') : null, }, { name: 'Team', @@ -137,7 +113,7 @@ export function SubscriptionModal({ open, onOpenChange }: SubscriptionModalProps { text: 'Dedicated Slack channel', included: true, icon: MessageSquare }, ], isActive: subscription.isTeam, - action: !subscription.isTeam ? () => handleUpgrade('team') : null, + action: !subscription.isTeam ? () => handleUpgradeWithErrorHandling('team') : null, }, { name: 'Enterprise', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx index d4e27e7f2..16e9648a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx @@ -22,7 +22,7 @@ const PLAN_NAMES = { } as const interface UsageIndicatorProps { - onClick?: (badgeType: 'add' | 'upgrade') => void + onClick?: () => void } export function UsageIndicator({ onClick }: UsageIndicatorProps) { @@ -39,7 +39,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { // Show skeleton while loading if (isLoading) { return ( -
onClick?.('upgrade')}> +
onClick?.()}>
{/* Plan and usage info skeleton */}
@@ -67,12 +67,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { : 'free' // Determine badge to show - const showAddBadge = planType !== 'free' && usage.percentUsed >= 50 - const badgeText = planType === 'free' ? 'Upgrade' : 'Add' - const badgeType = planType === 'free' ? 'upgrade' : 'add' + const billingStatus = useSubscriptionStore.getState().getBillingStatus() + const isBlocked = billingStatus === 'blocked' + const badgeText = isBlocked ? 'Payment Failed' : planType === 'free' ? 'Upgrade' : undefined return ( -
onClick?.(badgeType)}> +
onClick?.()}>
{/* Plan and usage info */}
@@ -85,17 +85,15 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { > {PLAN_NAMES[planType]} - {(showAddBadge || planType === 'free') && ( - {badgeText} - )} + {badgeText ? {badgeText} : null}
- ${usage.current.toFixed(2)} / ${usage.limit} + {isBlocked ? 'Payment required' : `$${usage.current.toFixed(2)} / $${usage.limit}`}
- {/* Progress Bar */} - + {/* Progress Bar with color: yellow for warning, red for full/blocked */} +
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 4f63be24c..1ac669292 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -32,6 +32,7 @@ import { getKeyboardShortcutText, useGlobalShortcuts, } from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts' +import { useSubscriptionStore } from '@/stores/subscription/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -115,7 +116,7 @@ export function Sidebar() { const [isTemplatesLoading, setIsTemplatesLoading] = useState(false) // Refs - const workflowScrollAreaRef = useRef(null) + const workflowScrollAreaRef = useRef(null) const workspaceIdRef = useRef(workspaceId) const routerRef = useRef>(router) const isInitializedRef = useRef(false) @@ -930,13 +931,15 @@ export function Sidebar() { }`} >
- - + +
+ +
{!isLoading && ( @@ -1003,16 +1006,15 @@ export function Sidebar() { style={{ bottom: `${navigationBottom + SIDEBAR_HEIGHTS.NAVIGATION + SIDEBAR_GAP}px` }} // Navigation height + gap > { - if (badgeType === 'add') { - // Open settings modal on subscription tab + onClick={() => { + const isBlocked = useSubscriptionStore.getState().getBillingStatus() === 'blocked' + if (isBlocked) { if (typeof window !== 'undefined') { window.dispatchEvent( new CustomEvent('open-settings', { detail: { tab: 'subscription' } }) ) } } else { - // Open subscription modal for upgrade setShowSubscriptionModal(true) } }} diff --git a/apps/sim/db/migrations/0079_shocking_shriek.sql b/apps/sim/db/migrations/0079_shocking_shriek.sql new file mode 100644 index 000000000..fe259b3fc --- /dev/null +++ b/apps/sim/db/migrations/0079_shocking_shriek.sql @@ -0,0 +1,8 @@ +ALTER TABLE "subscription" DROP CONSTRAINT "check_enterprise_metadata";--> statement-breakpoint +ALTER TABLE "organization" ADD COLUMN "org_usage_limit" numeric;--> statement-breakpoint +ALTER TABLE "user_stats" ALTER COLUMN "current_usage_limit" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "user_stats" ADD COLUMN IF NOT EXISTS "billing_blocked" boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE "user_stats" DROP COLUMN "usage_limit_set_by";--> statement-breakpoint +ALTER TABLE "user_stats" DROP COLUMN "billing_period_start";--> statement-breakpoint +ALTER TABLE "user_stats" DROP COLUMN "billing_period_end";--> statement-breakpoint +ALTER TABLE "subscription" ADD CONSTRAINT "check_enterprise_metadata" CHECK (plan != 'enterprise' OR metadata IS NOT NULL); \ No newline at end of file diff --git a/apps/sim/db/migrations/meta/0079_snapshot.json b/apps/sim/db/migrations/meta/0079_snapshot.json new file mode 100644 index 000000000..9f241f911 --- /dev/null +++ b/apps/sim/db/migrations/meta/0079_snapshot.json @@ -0,0 +1,5818 @@ +{ + "id": "7784589c-f02a-4a93-bc0e-a67a88e8632f", + "prevId": "c55e1379-1b36-49f6-8371-433ee9d39c8b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subdomain": { + "name": "subdomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subdomain_idx": { + "name": "subdomain_idx", + "columns": [ + { + "expression": "subdomain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_uploaded_at_idx": { + "name": "doc_kb_uploaded_at_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace": { + "name": "marketplace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "marketplace_workflow_id_workflow_id_fk": { + "name": "marketplace_workflow_id_workflow_id_fk", + "tableFrom": "marketplace", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_author_id_user_id_fk": { + "name": "marketplace_author_id_user_id_fk", + "tableFrom": "marketplace", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_idx": { + "name": "memory_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_key_idx": { + "name": "memory_workflow_key_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workflow_id_workflow_id_fk": { + "name": "memory_workflow_id_workflow_id_fk", + "tableFrom": "memory", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_fill_env_vars": { + "name": "auto_fill_env_vars", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_pan": { + "name": "auto_pan", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "console_expanded_by_default": { + "name": "console_expanded_by_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "general": { + "name": "general", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'FileText'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_workflow_id_idx": { + "name": "templates_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_user_id_idx": { + "name": "templates_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_idx": { + "name": "templates_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_views_idx": { + "name": "templates_category_views_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_category_stars_idx": { + "name": "templates_category_stars_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_user_category_idx": { + "name": "templates_user_category_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "templates_user_id_user_id_fk": { + "name": "templates_user_id_user_id_fk", + "tableFrom": "templates", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_rate_limits": { + "name": "user_rate_limits", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sync_api_requests": { + "name": "sync_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "async_api_requests": { + "name": "async_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "window_start": { + "name": "window_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_rate_limited": { + "name": "is_rate_limited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_reset_at": { + "name": "rate_limit_reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_rate_limits_user_id_user_id_fk": { + "name": "user_rate_limits_user_id_user_id_fk", + "tableFrom": "user_rate_limits", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'10'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_block_id_workflow_blocks_id_fk": { + "name": "webhook_block_id_workflow_blocks_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_state": { + "name": "deployed_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned_api_key": { + "name": "pinned_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collaborators": { + "name": "collaborators", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marketplace_data": { + "name": "marketplace_data", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extent": { + "name": "extent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_parent_id_idx": { + "name": "workflow_blocks_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_workflow_parent_idx": { + "name": "workflow_blocks_workflow_parent_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_workflow_type_idx": { + "name": "workflow_blocks_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_source_block_idx": { + "name": "workflow_edges_source_block_idx", + "columns": [ + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_target_block_idx": { + "name": "workflow_edges_target_block_idx", + "columns": [ + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_idx": { + "name": "workflow_execution_logs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_unique": { + "name": "workflow_schedule_workflow_block_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_block_id_workflow_blocks_id_fk": { + "name": "workflow_schedule_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/sim/db/migrations/meta/_journal.json b/apps/sim/db/migrations/meta/_journal.json index 1b83edc5f..628753c53 100644 --- a/apps/sim/db/migrations/meta/_journal.json +++ b/apps/sim/db/migrations/meta/_journal.json @@ -547,6 +547,13 @@ "when": 1756168384465, "tag": "0078_supreme_madrox", "breakpoints": true + }, + { + "idx": 79, + "version": "7", + "when": 1756246702112, + "tag": "0079_shocking_shriek", + "breakpoints": true } ] } diff --git a/apps/sim/db/schema.ts b/apps/sim/db/schema.ts index 9d9d7601a..1381fb2b4 100644 --- a/apps/sim/db/schema.ts +++ b/apps/sim/db/schema.ts @@ -441,21 +441,17 @@ export const userStats = pgTable('user_stats', { totalChatExecutions: integer('total_chat_executions').notNull().default(0), totalTokensUsed: integer('total_tokens_used').notNull().default(0), totalCost: decimal('total_cost').notNull().default('0'), - currentUsageLimit: decimal('current_usage_limit') - .notNull() - .default(DEFAULT_FREE_CREDITS.toString()), // Default $10 for free plan - usageLimitSetBy: text('usage_limit_set_by'), // User ID who set the limit (for team admin tracking) + currentUsageLimit: decimal('current_usage_limit').default(DEFAULT_FREE_CREDITS.toString()), // Default $10 for free plan, null for team/enterprise usageLimitUpdatedAt: timestamp('usage_limit_updated_at').defaultNow(), // Billing period tracking currentPeriodCost: decimal('current_period_cost').notNull().default('0'), // Usage in current billing period - billingPeriodStart: timestamp('billing_period_start').defaultNow(), // When current billing period started - billingPeriodEnd: timestamp('billing_period_end'), // When current billing period ends lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period // Copilot usage tracking totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'), totalCopilotTokens: integer('total_copilot_tokens').notNull().default(0), totalCopilotCalls: integer('total_copilot_calls').notNull().default(0), lastActive: timestamp('last_active').notNull().defaultNow(), + billingBlocked: boolean('billing_blocked').notNull().default(false), }) export const customTools = pgTable('custom_tools', { @@ -494,7 +490,7 @@ export const subscription = pgTable( ), enterpriseMetadataCheck: check( 'check_enterprise_metadata', - sql`plan != 'enterprise' OR (metadata IS NOT NULL AND (metadata->>'perSeatAllowance' IS NOT NULL OR metadata->>'totalAllowance' IS NOT NULL))` + sql`plan != 'enterprise' OR metadata IS NOT NULL` ), }) ) @@ -552,6 +548,7 @@ export const organization = pgTable('organization', { slug: text('slug').notNull(), logo: text('logo'), metadata: json('metadata'), + orgUsageLimit: decimal('org_usage_limit'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }) diff --git a/apps/sim/hooks/use-subscription-state.ts b/apps/sim/hooks/use-subscription-state.ts index 93dffb004..0eeddeb51 100644 --- a/apps/sim/hooks/use-subscription-state.ts +++ b/apps/sim/hooks/use-subscription-state.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState } from 'react' import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants' -import type { SubscriptionFeatures } from '@/lib/billing/types' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('useSubscriptionState') @@ -25,7 +24,6 @@ interface SubscriptionState { status: string | null seats: number | null metadata: any | null - features: SubscriptionFeatures usage: UsageData } @@ -82,12 +80,6 @@ export function useSubscriptionState() { metadata: data?.metadata, }, - features: { - sharingEnabled: data?.features?.sharingEnabled ?? false, - multiplayerEnabled: data?.features?.multiplayerEnabled ?? false, - workspaceCollaborationEnabled: data?.features?.workspaceCollaborationEnabled ?? false, - }, - usage: { current: data?.usage?.current ?? 0, limit: data?.usage?.limit ?? DEFAULT_FREE_CREDITS, @@ -107,10 +99,6 @@ export function useSubscriptionState() { error, refetch, - hasFeature: (feature: keyof SubscriptionFeatures) => { - return data?.features?.[feature] ?? false - }, - isAtLeastPro: () => { return data?.isPro || data?.isTeam || data?.isEnterprise || false }, diff --git a/apps/sim/lib/auth-client.ts b/apps/sim/lib/auth-client.ts index d2271264c..448ba9ae0 100644 --- a/apps/sim/lib/auth-client.ts +++ b/apps/sim/lib/auth-client.ts @@ -9,7 +9,7 @@ import { import { createAuthClient } from 'better-auth/react' import type { auth } from '@/lib/auth' import { env, getEnv } from '@/lib/env' -import { isDev, isProd } from '@/lib/environment' +import { isProd } from '@/lib/environment' import { SessionContext, type SessionHookResult } from '@/lib/session-context' export function getBaseURL() { @@ -59,19 +59,6 @@ export function useSession(): SessionHookResult { export const { useActiveOrganization } = client export const useSubscription = () => { - // In development, provide mock implementations - if (isDev) { - return { - list: async () => ({ data: [] }), - upgrade: async () => ({ - error: { message: 'Subscriptions are disabled in development mode' }, - }), - cancel: async () => ({ data: null }), - restore: async () => ({ data: null }), - } - } - - // In production, use the real implementation return { list: client.subscription?.list, upgrade: client.subscription?.upgrade, diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index f5fe2fa11..f1f2a6c3f 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -20,7 +20,16 @@ import { renderPasswordResetEmail, } from '@/components/emails/render-email' import { getBaseURL } from '@/lib/auth-client' -import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants' +import { authorizeSubscriptionReference } from '@/lib/billing/authorization' +import { handleNewUser } from '@/lib/billing/core/usage' +import { syncSubscriptionUsageLimits } from '@/lib/billing/organization' +import { getPlans } from '@/lib/billing/plans' +import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types' +import { + handleInvoiceFinalized, + handleInvoicePaymentFailed, + handleInvoicePaymentSucceeded, +} from '@/lib/billing/webhooks/invoices' import { sendEmail } from '@/lib/email/mailer' import { getFromEmailAddress } from '@/lib/email/utils' import { quickValidateEmail } from '@/lib/email/validation' @@ -34,10 +43,7 @@ const logger = createLogger('Auth') // Only initialize Stripe if the key is provided // This allows local development without a Stripe account -const validStripeKey = - env.STRIPE_SECRET_KEY && - env.STRIPE_SECRET_KEY.trim() !== '' && - env.STRIPE_SECRET_KEY !== 'placeholder' +const validStripeKey = env.STRIPE_SECRET_KEY let stripeClient = null if (validStripeKey) { @@ -46,6 +52,121 @@ if (validStripeKey) { }) } +function isEnterpriseMetadata(value: unknown): value is EnterpriseSubscriptionMetadata { + return ( + !!value && + typeof (value as any).plan === 'string' && + (value as any).plan.toLowerCase() === 'enterprise' + ) +} + +async function handleManualEnterpriseSubscription(event: Stripe.Event) { + const stripeSubscription = event.data.object as Stripe.Subscription + + const metaPlan = (stripeSubscription.metadata?.plan as string | undefined)?.toLowerCase() || '' + + if (metaPlan !== 'enterprise') { + logger.info('[subscription.created] Skipping non-enterprise subscription', { + subscriptionId: stripeSubscription.id, + plan: metaPlan || 'unknown', + }) + return + } + + const stripeCustomerId = stripeSubscription.customer as string + + if (!stripeCustomerId) { + logger.error('[subscription.created] Missing Stripe customer ID', { + subscriptionId: stripeSubscription.id, + }) + throw new Error('Missing Stripe customer ID on subscription') + } + + const metadata = stripeSubscription.metadata || {} + + const referenceId = + typeof metadata.referenceId === 'string' && metadata.referenceId.length > 0 + ? metadata.referenceId + : null + + if (!referenceId) { + logger.error('[subscription.created] Unable to resolve referenceId', { + subscriptionId: stripeSubscription.id, + stripeCustomerId, + }) + throw new Error('Unable to resolve referenceId for subscription') + } + + const firstItem = stripeSubscription.items?.data?.[0] + const seats = typeof firstItem?.quantity === 'number' ? firstItem.quantity : null + + if (!isEnterpriseMetadata(metadata)) { + logger.error('[subscription.created] Invalid enterprise metadata shape', { + subscriptionId: stripeSubscription.id, + metadata, + }) + throw new Error('Invalid enterprise metadata for subscription') + } + const enterpriseMetadata = metadata + const metadataJson: Record = { ...enterpriseMetadata } + + const subscriptionRow = { + id: crypto.randomUUID(), + plan: 'enterprise', + referenceId, + stripeCustomerId, + stripeSubscriptionId: stripeSubscription.id, + status: stripeSubscription.status || null, + periodStart: stripeSubscription.current_period_start + ? new Date(stripeSubscription.current_period_start * 1000) + : null, + periodEnd: stripeSubscription.current_period_end + ? new Date(stripeSubscription.current_period_end * 1000) + : null, + cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null, + seats, + trialStart: stripeSubscription.trial_start + ? new Date(stripeSubscription.trial_start * 1000) + : null, + trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null, + metadata: metadataJson, + } + + const existing = await db + .select({ id: schema.subscription.id }) + .from(schema.subscription) + .where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id)) + .limit(1) + + if (existing.length > 0) { + await db + .update(schema.subscription) + .set({ + plan: subscriptionRow.plan, + referenceId: subscriptionRow.referenceId, + stripeCustomerId: subscriptionRow.stripeCustomerId, + status: subscriptionRow.status, + periodStart: subscriptionRow.periodStart, + periodEnd: subscriptionRow.periodEnd, + cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd, + seats: subscriptionRow.seats, + trialStart: subscriptionRow.trialStart, + trialEnd: subscriptionRow.trialEnd, + metadata: subscriptionRow.metadata, + }) + .where(eq(schema.subscription.stripeSubscriptionId, stripeSubscription.id)) + } else { + await db.insert(schema.subscription).values(subscriptionRow) + } + + logger.info('[subscription.created] Upserted subscription', { + subscriptionId: subscriptionRow.id, + referenceId: subscriptionRow.referenceId, + plan: subscriptionRow.plan, + status: subscriptionRow.status, + }) +} + export const auth = betterAuth({ baseURL: getBaseURL(), trustedOrigins: [ @@ -1152,19 +1273,16 @@ export const auth = betterAuth({ stripeClient, stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET || '', createCustomerOnSignUp: true, - onCustomerCreate: async ({ stripeCustomer, user }, request) => { - logger.info('Stripe customer created', { - customerId: stripeCustomer.id, + onCustomerCreate: async ({ stripeCustomer, user }) => { + logger.info('[onCustomerCreate] Stripe customer created', { + stripeCustomerId: stripeCustomer.id, userId: user.id, }) - // Initialize usage limits for new user try { - const { initializeUserUsageLimit } = await import('@/lib/billing') - await initializeUserUsageLimit(user.id) - logger.info('Usage limits initialized for new user', { userId: user.id }) + await handleNewUser(user.id) } catch (error) { - logger.error('Failed to initialize usage limits for new user', { + logger.error('[onCustomerCreate] Failed to handle new user setup', { userId: user.id, error, }) @@ -1172,61 +1290,11 @@ export const auth = betterAuth({ }, subscription: { enabled: true, - plans: [ - { - name: 'free', - priceId: env.STRIPE_FREE_PRICE_ID || '', - limits: { - cost: env.FREE_TIER_COST_LIMIT ?? DEFAULT_FREE_CREDITS, - sharingEnabled: 0, - multiplayerEnabled: 0, - workspaceCollaborationEnabled: 0, - }, - }, - { - name: 'pro', - priceId: env.STRIPE_PRO_PRICE_ID || '', - limits: { - cost: env.PRO_TIER_COST_LIMIT ?? 20, - sharingEnabled: 1, - multiplayerEnabled: 0, - workspaceCollaborationEnabled: 0, - }, - }, - { - name: 'team', - priceId: env.STRIPE_TEAM_PRICE_ID || '', - limits: { - cost: env.TEAM_TIER_COST_LIMIT ?? 40, // $40 per seat - sharingEnabled: 1, - multiplayerEnabled: 1, - workspaceCollaborationEnabled: 1, - }, - }, - ], - authorizeReference: async ({ user, referenceId, action }) => { - // User can always manage their own subscriptions - if (referenceId === user.id) { - return true - } - - // Check if referenceId is an organizationId the user has admin rights to - const members = await db - .select() - .from(schema.member) - .where( - and( - eq(schema.member.userId, user.id), - eq(schema.member.organizationId, referenceId) - ) - ) - - const member = members[0] - - // Allow if the user is an owner or admin of the organization - return member?.role === 'owner' || member?.role === 'admin' + plans: getPlans(), + authorizeReference: async ({ user, referenceId }) => { + return await authorizeSubscriptionReference(user.id, referenceId) }, - getCheckoutSessionParams: async ({ user, plan, subscription }, request) => { + getCheckoutSessionParams: async ({ plan, subscription }) => { if (plan.name === 'team') { return { params: { @@ -1253,127 +1321,128 @@ export const auth = betterAuth({ } }, onSubscriptionComplete: async ({ - event, - stripeSubscription, subscription, }: { event: Stripe.Event stripeSubscription: Stripe.Subscription subscription: any }) => { - logger.info('Subscription created', { + logger.info('[onSubscriptionComplete] Subscription created', { subscriptionId: subscription.id, referenceId: subscription.referenceId, plan: subscription.plan, status: subscription.status, }) - // Auto-create organization for team plan purchases + // Sync usage limits for the new subscription try { - const { handleTeamPlanOrganization } = await import( - '@/lib/billing/team-management' - ) - await handleTeamPlanOrganization(subscription) + await syncSubscriptionUsageLimits(subscription) } catch (error) { - logger.error('Failed to handle team plan organization creation', { + logger.error('[onSubscriptionComplete] Failed to sync usage limits', { subscriptionId: subscription.id, referenceId: subscription.referenceId, error, }) } - - // Initialize billing period and sync usage limits - try { - const { initializeBillingPeriod } = await import( - '@/lib/billing/core/billing-periods' - ) - const { syncSubscriptionUsageLimits } = await import( - '@/lib/billing/team-management' - ) - - // Sync usage limits for user or organization members - await syncSubscriptionUsageLimits(subscription) - - // Initialize billing period for new subscription using Stripe dates - if (subscription.plan !== 'free') { - const stripeStart = new Date(stripeSubscription.current_period_start * 1000) - const stripeEnd = new Date(stripeSubscription.current_period_end * 1000) - - await initializeBillingPeriod(subscription.referenceId, stripeStart, stripeEnd) - logger.info( - 'Billing period initialized for new subscription with Stripe dates', - { - referenceId: subscription.referenceId, - billingStart: stripeStart, - billingEnd: stripeEnd, - } - ) - } - } catch (error) { - logger.error( - 'Failed to sync usage limits or initialize billing period after subscription creation', - { - referenceId: subscription.referenceId, - error, - } - ) - } }, onSubscriptionUpdate: async ({ - event, subscription, }: { event: Stripe.Event subscription: any }) => { - logger.info('Subscription updated', { + logger.info('[onSubscriptionUpdate] Subscription updated', { subscriptionId: subscription.id, status: subscription.status, plan: subscription.plan, }) - // Auto-create organization for team plan upgrades (free -> team) try { - const { handleTeamPlanOrganization } = await import( - '@/lib/billing/team-management' - ) - await handleTeamPlanOrganization(subscription) - } catch (error) { - logger.error('Failed to handle team plan organization creation on update', { - subscriptionId: subscription.id, - referenceId: subscription.referenceId, - error, - }) - } - - // Sync usage limits for the user/organization - try { - const { syncSubscriptionUsageLimits } = await import( - '@/lib/billing/team-management' - ) await syncSubscriptionUsageLimits(subscription) } catch (error) { - logger.error('Failed to sync usage limits after subscription update', { + logger.error('[onSubscriptionUpdate] Failed to sync usage limits', { + subscriptionId: subscription.id, referenceId: subscription.referenceId, error, }) } }, onSubscriptionDeleted: async ({ - event, - stripeSubscription, subscription, }: { event: Stripe.Event stripeSubscription: Stripe.Subscription subscription: any }) => { - logger.info('Subscription deleted', { + logger.info('[onSubscriptionDeleted] Subscription deleted', { subscriptionId: subscription.id, referenceId: subscription.referenceId, }) + + // Reset usage limits back to free tier defaults + try { + // This will sync limits based on the now-inactive subscription (defaulting to free tier) + await syncSubscriptionUsageLimits(subscription) + + logger.info('[onSubscriptionDeleted] Reset usage limits to free tier', { + subscriptionId: subscription.id, + referenceId: subscription.referenceId, + }) + } catch (error) { + logger.error('[onSubscriptionDeleted] Failed to reset usage limits', { + subscriptionId: subscription.id, + referenceId: subscription.referenceId, + error, + }) + } }, }, + onEvent: async (event: Stripe.Event) => { + logger.info('[onEvent] Received Stripe webhook', { + eventId: event.id, + eventType: event.type, + }) + + try { + // Handle invoice events + switch (event.type) { + case 'invoice.payment_succeeded': { + await handleInvoicePaymentSucceeded(event) + break + } + case 'invoice.payment_failed': { + await handleInvoicePaymentFailed(event) + break + } + case 'invoice.finalized': { + await handleInvoiceFinalized(event) + break + } + case 'customer.subscription.created': { + await handleManualEnterpriseSubscription(event) + break + } + default: + logger.info('[onEvent] Ignoring unsupported webhook event', { + eventId: event.id, + eventType: event.type, + }) + break + } + + logger.info('[onEvent] Successfully processed webhook', { + eventId: event.id, + eventType: event.type, + }) + } catch (error) { + logger.error('[onEvent] Failed to process webhook', { + eventId: event.id, + eventType: event.type, + error, + }) + throw error // Re-throw to signal webhook failure to Stripe + } + }, }), // Add organization plugin as a separate entry in the plugins array organization({ @@ -1405,10 +1474,12 @@ export const auth = betterAuth({ ) ) - const teamSubscription = subscriptions.find((sub) => sub.plan === 'team') + const teamOrEnterpriseSubscription = subscriptions.find( + (sub) => sub.plan === 'team' || sub.plan === 'enterprise' + ) - if (!teamSubscription) { - throw new Error('No active team subscription for this organization') + if (!teamOrEnterpriseSubscription) { + throw new Error('No active team or enterprise subscription for this organization') } const members = await db @@ -1427,7 +1498,7 @@ export const auth = betterAuth({ ) const totalCount = members.length + pendingInvites.length - const seatLimit = teamSubscription.seats || 1 + const seatLimit = teamOrEnterpriseSubscription.seats || 1 if (totalCount >= seatLimit) { throw new Error(`Organization has reached its seat limit of ${seatLimit}`) @@ -1463,8 +1534,8 @@ export const auth = betterAuth({ } }, organizationCreation: { - afterCreate: async ({ organization, member, user }) => { - logger.info('Organization created', { + afterCreate: async ({ organization, user }) => { + logger.info('[organizationCreation.afterCreate] Organization created', { organizationId: organization.id, creatorId: user.id, }) diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts new file mode 100644 index 000000000..bbde03d9a --- /dev/null +++ b/apps/sim/lib/billing/authorization.ts @@ -0,0 +1,28 @@ +import { and, eq } from 'drizzle-orm' +import { db } from '@/db' +import * as schema from '@/db/schema' + +/** + * Check if a user is authorized to manage billing for a given reference ID + * Reference ID can be either a user ID (individual subscription) or organization ID (team subscription) + */ +export async function authorizeSubscriptionReference( + userId: string, + referenceId: string +): Promise { + // User can always manage their own subscriptions + if (referenceId === userId) { + return true + } + + // Check if referenceId is an organizationId the user has admin rights to + const members = await db + .select() + .from(schema.member) + .where(and(eq(schema.member.userId, userId), eq(schema.member.organizationId, referenceId))) + + const member = members[0] + + // Allow if the user is an owner or admin of the organization + return member?.role === 'owner' || member?.role === 'admin' +} diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index d451ee768..c9c6c4e8f 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -1,9 +1,10 @@ -import { eq } from 'drizzle-orm' +import { eq, inArray } from 'drizzle-orm' +import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing' import { getUserUsageLimit } from '@/lib/billing/core/usage' import { isBillingEnabled } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' -import { userStats } from '@/db/schema' +import { member, organization, userStats } from '@/db/schema' const logger = createLogger('UsageMonitor') @@ -44,7 +45,7 @@ export async function checkUsageStatus(userId: string): Promise { } } - // Get usage limit from user_stats (new method) + // Get usage limit from user_stats (per-user cap) const limit = await getUserUsageLimit(userId) logger.info('Using stored usage limit', { userId, limit }) @@ -70,11 +71,71 @@ export async function checkUsageStatus(userId: string): Promise { ) // Calculate percentage used - const percentUsed = Math.min(Math.round((currentUsage / limit) * 100), 100) + const percentUsed = Math.min(Math.floor((currentUsage / limit) * 100), 100) - // Check if usage exceeds threshold or limit - const isWarning = percentUsed >= WARNING_THRESHOLD && percentUsed < 100 - const isExceeded = currentUsage >= limit + // Check org-level cap for team/enterprise pooled usage + let isExceeded = currentUsage >= limit + let isWarning = percentUsed >= WARNING_THRESHOLD && percentUsed < 100 + try { + const memberships = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, userId)) + if (memberships.length > 0) { + for (const m of memberships) { + const orgRows = await db + .select({ id: organization.id, orgUsageLimit: organization.orgUsageLimit }) + .from(organization) + .where(eq(organization.id, m.organizationId)) + .limit(1) + if (orgRows.length) { + const org = orgRows[0] + // Sum pooled usage + const teamMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, org.id)) + + // Get all team member usage in a single query to avoid N+1 + let pooledUsage = 0 + if (teamMembers.length > 0) { + const memberIds = teamMembers.map((tm) => tm.userId) + const allMemberStats = await db + .select({ current: userStats.currentPeriodCost, total: userStats.totalCost }) + .from(userStats) + .where(inArray(userStats.userId, memberIds)) + + for (const stats of allMemberStats) { + pooledUsage += Number.parseFloat( + stats.current?.toString() || stats.total.toString() + ) + } + } + // Determine org cap + let orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0 + if (!orgCap || Number.isNaN(orgCap)) { + // Fall back to minimum billing amount from Stripe subscription + const orgSub = await getOrganizationSubscription(org.id) + if (orgSub?.seats) { + const { basePrice } = getPlanPricing(orgSub.plan, orgSub) + orgCap = (orgSub.seats || 1) * basePrice + } else { + // If no subscription, use team default + const { basePrice } = getPlanPricing('team') + orgCap = basePrice // Default to 1 seat minimum + } + } + if (pooledUsage >= orgCap) { + isExceeded = true + isWarning = false + break + } + } + } + } + } catch (error) { + logger.warn('Error checking organization usage limits', { error, userId }) + } logger.info('Final usage statistics', { userId, @@ -193,6 +254,28 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{ logger.info('Server-side checking usage limits for user', { userId }) + // Hard block if billing is flagged as blocked + const stats = await db + .select({ + blocked: userStats.billingBlocked, + current: userStats.currentPeriodCost, + total: userStats.totalCost, + }) + .from(userStats) + .where(eq(userStats.userId, userId)) + .limit(1) + if (stats.length > 0 && stats[0].blocked) { + const currentUsage = Number.parseFloat( + stats[0].current?.toString() || stats[0].total.toString() + ) + return { + isExceeded: true, + currentUsage, + limit: 0, + message: 'Billing issue detected. Please update your payment method to continue.', + } + } + // Get usage data using the same function we use for client-side const usageData = await checkUsageStatus(userId) diff --git a/apps/sim/lib/billing/constants.ts b/apps/sim/lib/billing/constants.ts index cc9c1b50a..f633f03f4 100644 --- a/apps/sim/lib/billing/constants.ts +++ b/apps/sim/lib/billing/constants.ts @@ -3,10 +3,17 @@ */ /** - * Default free credits (in dollars) for new users + * Fallback free credits (in dollars) when env var is not set */ export const DEFAULT_FREE_CREDITS = 10 +/** + * Default per-user minimum limits (in dollars) for paid plans when env vars are absent + */ +export const DEFAULT_PRO_TIER_COST_LIMIT = 20 +export const DEFAULT_TEAM_TIER_COST_LIMIT = 40 +export const DEFAULT_ENTERPRISE_TIER_COST_LIMIT = 200 + /** * Base charge applied to every workflow execution * This charge is applied regardless of whether the workflow uses AI models diff --git a/apps/sim/lib/billing/core/billing-periods.test.ts b/apps/sim/lib/billing/core/billing-periods.test.ts deleted file mode 100644 index 9b52c90cd..000000000 --- a/apps/sim/lib/billing/core/billing-periods.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - calculateBillingPeriod, - calculateNextBillingPeriod, -} from '@/lib/billing/core/billing-periods' - -vi.mock('@/lib/logs/console/logger', () => ({ - createLogger: vi.fn().mockReturnValue({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), -})) - -describe('Billing Period Calculations', () => { - beforeEach(() => { - vi.useFakeTimers() - // Set consistent date for testing - vi.setSystemTime(new Date('2024-07-06T10:00:00Z')) - }) - - afterEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - }) - - describe('calculateBillingPeriod', () => { - it.concurrent('calculates current period from subscription dates when within period', () => { - vi.setSystemTime(new Date('2024-01-20T00:00:00Z')) // Within the subscription period - - const subscriptionStart = new Date('2024-01-15T00:00:00Z') - const subscriptionEnd = new Date('2024-02-15T00:00:00Z') - - const period = calculateBillingPeriod(subscriptionStart, subscriptionEnd) - - expect(period.start).toEqual(subscriptionStart) - expect(period.end).toEqual(subscriptionEnd) - }) - - it.concurrent('calculates next period when current period has ended', () => { - vi.setSystemTime(new Date('2024-03-01T00:00:00Z')) - - const subscriptionStart = new Date('2024-01-15T00:00:00Z') - const subscriptionEnd = new Date('2024-02-15T00:00:00Z') - - const period = calculateBillingPeriod(subscriptionStart, subscriptionEnd) - - expect(period.start).toEqual(subscriptionEnd) - // Expect month-based calculation: Feb 15 + 1 month = Mar 15 - expect(period.end.getUTCFullYear()).toBe(2024) - expect(period.end.getUTCMonth()).toBe(2) // March (0-indexed) - expect(period.end.getUTCDate()).toBe(15) - }) - - it.concurrent('calculates monthly periods from subscription start date', () => { - vi.setSystemTime(new Date('2024-01-20T00:00:00Z')) - - const subscriptionStart = new Date('2024-01-15T00:00:00Z') - - const period = calculateBillingPeriod(subscriptionStart) - - expect(period.start).toEqual(subscriptionStart) - expect(period.end).toEqual(new Date('2024-02-15T00:00:00Z')) - }) - - it.concurrent('advances periods when past end date', () => { - vi.setSystemTime(new Date('2024-03-20T00:00:00Z')) - - const subscriptionStart = new Date('2024-01-15T00:00:00Z') - - const period = calculateBillingPeriod(subscriptionStart) - - expect(period.start).toEqual(new Date('2024-03-15T00:00:00Z')) - expect(period.end).toEqual(new Date('2024-04-15T00:00:00Z')) - }) - - it.concurrent('falls back to calendar month when no subscription data', () => { - vi.setSystemTime(new Date('2024-07-06T10:00:00Z')) - - const period = calculateBillingPeriod() - - expect(period.start.getUTCFullYear()).toBe(2024) - expect(period.start.getUTCMonth()).toBe(6) // July (0-indexed) - expect(period.start.getUTCDate()).toBe(1) - expect(period.end.getUTCFullYear()).toBe(2024) - expect(period.end.getUTCMonth()).toBe(6) // July (0-indexed) - expect(period.end.getUTCDate()).toBe(31) - }) - }) - - describe('calculateNextBillingPeriod', () => { - it.concurrent('calculates next period from given end date', () => { - const periodEnd = new Date('2024-02-15T00:00:00Z') - - const nextPeriod = calculateNextBillingPeriod(periodEnd) - - expect(nextPeriod.start).toEqual(periodEnd) - expect(nextPeriod.end.getUTCFullYear()).toBe(2024) - expect(nextPeriod.end.getUTCMonth()).toBe(2) // March (0-indexed) - expect(nextPeriod.end.getUTCDate()).toBe(15) - }) - - it.concurrent('handles month transitions correctly', () => { - const periodEnd = new Date('2024-01-31T00:00:00Z') - - const nextPeriod = calculateNextBillingPeriod(periodEnd) - - expect(nextPeriod.start).toEqual(periodEnd) - // JavaScript's setUTCMonth handles overflow: Jan 31 + 1 month = Mar 2 (Feb 29 + 2 days in 2024) - expect(nextPeriod.end.getUTCMonth()).toBe(2) // March (0-indexed) due to overflow - }) - }) - - describe('Period Alignment Scenarios', () => { - it.concurrent('aligns with mid-month subscription perfectly', () => { - vi.setSystemTime(new Date('2024-03-20T00:00:00Z')) // Within the subscription period - - const midMonthStart = new Date('2024-03-15T10:30:00Z') - const midMonthEnd = new Date('2024-04-15T10:30:00Z') - - const period = calculateBillingPeriod(midMonthStart, midMonthEnd) - - expect(period.start.getTime()).toBe(midMonthStart.getTime()) - expect(period.end.getTime()).toBe(midMonthEnd.getTime()) - }) - - it.concurrent('handles annual subscriptions correctly', () => { - vi.setSystemTime(new Date('2024-06-15T00:00:00Z')) // Within the annual subscription period - - const annualStart = new Date('2024-01-01T00:00:00Z') - const annualEnd = new Date('2025-01-01T00:00:00Z') - - const period = calculateBillingPeriod(annualStart, annualEnd) - - expect(period.start.getTime()).toBe(annualStart.getTime()) - expect(period.end.getTime()).toBe(annualEnd.getTime()) - }) - }) - - describe('Billing Check Scenarios', () => { - it.concurrent('identifies subscriptions ending today', () => { - const today = new Date('2024-07-06T00:00:00Z') - vi.setSystemTime(today) - - const endingToday = new Date(today) - const shouldBill = endingToday.toDateString() === today.toDateString() - - expect(shouldBill).toBe(true) - }) - - it.concurrent('excludes subscriptions ending tomorrow', () => { - const today = new Date('2024-07-06T00:00:00Z') - vi.setSystemTime(today) - - const endingTomorrow = new Date(today) - endingTomorrow.setUTCDate(endingTomorrow.getUTCDate() + 1) - - const shouldBill = endingTomorrow.toDateString() === today.toDateString() - - expect(shouldBill).toBe(false) - }) - - it.concurrent('excludes subscriptions that ended yesterday', () => { - const today = new Date('2024-07-06T00:00:00Z') - vi.setSystemTime(today) - - const endedYesterday = new Date(today) - endedYesterday.setUTCDate(endedYesterday.getUTCDate() - 1) - - const shouldBill = endedYesterday.toDateString() === today.toDateString() - - expect(shouldBill).toBe(false) - }) - }) -}) diff --git a/apps/sim/lib/billing/core/billing-periods.ts b/apps/sim/lib/billing/core/billing-periods.ts deleted file mode 100644 index 96cec45da..000000000 --- a/apps/sim/lib/billing/core/billing-periods.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { and, eq } from 'drizzle-orm' -import { createLogger } from '@/lib/logs/console/logger' -import { db } from '@/db' -import { member, subscription, userStats } from '@/db/schema' - -const logger = createLogger('BillingPeriodManager') - -/** - * Calculate billing period dates based on subscription for proper Stripe alignment - * Supports both subscription start date and full period alignment - */ -export function calculateBillingPeriod( - subscriptionPeriodStart?: Date, - subscriptionPeriodEnd?: Date -): { - start: Date - end: Date -} { - const now = new Date() - - // If we have both subscription dates, use them for perfect alignment - if (subscriptionPeriodStart && subscriptionPeriodEnd) { - const start = new Date(subscriptionPeriodStart) - const end = new Date(subscriptionPeriodEnd) - - // If we're past the current period, calculate the next period using calendar months - if (now >= end) { - const newStart = new Date(end) - const newEnd = new Date(end) - - // Use UTC methods to avoid timezone issues - newEnd.setUTCMonth(newEnd.getUTCMonth() + 1) - - logger.info('Calculated next billing period from subscription dates', { - originalStart: subscriptionPeriodStart, - originalEnd: subscriptionPeriodEnd, - newStart, - newEnd, - }) - - return { start: newStart, end: newEnd } - } - - logger.info('Using current subscription billing period', { - start, - end, - }) - - return { start, end } - } - - // If we only have subscription start date, calculate monthly periods from that date - if (subscriptionPeriodStart) { - const start = new Date(subscriptionPeriodStart) - const end = new Date(start) - - // Add one month to start date using UTC to avoid timezone issues - end.setUTCMonth(end.getUTCMonth() + 1) - - // If we're past the end date, calculate the current period - while (end <= now) { - start.setUTCMonth(start.getUTCMonth() + 1) - end.setUTCMonth(end.getUTCMonth() + 1) - } - - logger.info('Calculated billing period from subscription start date', { - subscriptionStart: subscriptionPeriodStart, - currentPeriodStart: start, - currentPeriodEnd: end, - }) - - return { start, end } - } - - // Fallback: Default monthly billing period (1st to last day of month) - // This should only be used for users without proper subscription data - const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) - const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0, 23, 59, 59, 999)) - - logger.warn('Using fallback calendar month billing period', { - start, - end, - }) - - return { start, end } -} - -/** - * Calculate the next billing period starting from a given period end date - */ -export function calculateNextBillingPeriod(periodEnd: Date): { - start: Date - end: Date -} { - const start = new Date(periodEnd) - const end = new Date(start) - - // Add one month for the next period using UTC to avoid timezone issues - end.setUTCMonth(end.getUTCMonth() + 1) - - logger.info('Calculated next billing period', { - previousPeriodEnd: periodEnd, - nextPeriodStart: start, - nextPeriodEnd: end, - }) - - return { start, end } -} - -/** - * Initialize billing period for a user based on their subscription - * Can optionally accept Stripe subscription dates to ensure proper alignment - */ -export async function initializeBillingPeriod( - userId: string, - stripeSubscriptionStart?: Date, - stripeSubscriptionEnd?: Date -): Promise { - try { - let start: Date - let end: Date - - if (stripeSubscriptionStart && stripeSubscriptionEnd) { - // Use Stripe subscription dates for perfect alignment - start = stripeSubscriptionStart - end = stripeSubscriptionEnd - logger.info('Using Stripe subscription dates for billing period', { - userId, - stripeStart: stripeSubscriptionStart, - stripeEnd: stripeSubscriptionEnd, - }) - } else { - // Fallback: Get user's subscription to determine billing period - const subscriptionData = await db - .select() - .from(subscription) - .where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active'))) - .limit(1) - - const billingPeriod = calculateBillingPeriod( - subscriptionData[0]?.periodStart || undefined, - subscriptionData[0]?.periodEnd || undefined - ) - start = billingPeriod.start - end = billingPeriod.end - } - - await db - .update(userStats) - .set({ - currentPeriodCost: '0', - }) - .where(eq(userStats.userId, userId)) - - logger.info('Billing period initialized for user', { - userId, - billingPeriodStart: start, - billingPeriodEnd: end, - }) - } catch (error) { - logger.error('Failed to initialize billing period', { userId, error }) - throw error - } -} - -/** - * Reset billing period for a user (archive current usage and start new period) - * Now properly calculates next period based on subscription billing cycle - */ -export async function resetUserBillingPeriod(userId: string): Promise { - try { - // Get current period data and subscription info before reset - const [currentStats, userSubscription] = await Promise.all([ - db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1), - db - .select() - .from(subscription) - .where(and(eq(subscription.referenceId, userId), eq(subscription.status, 'active'))) - .limit(1), - ]) - - if (currentStats.length === 0) { - logger.warn('No user stats found for billing period reset', { userId }) - return - } - - const stats = currentStats[0] - const currentPeriodCost = stats.currentPeriodCost || '0' - - // Calculate next billing period based on subscription or current period end - let newPeriodStart: Date - let newPeriodEnd: Date - - if (userSubscription.length > 0 && userSubscription[0].periodEnd) { - // Use subscription-based period calculation - const nextPeriod = calculateNextBillingPeriod(userSubscription[0].periodEnd) - newPeriodStart = nextPeriod.start - newPeriodEnd = nextPeriod.end - } else if (stats.billingPeriodEnd) { - // Use current billing period end to calculate next period - const nextPeriod = calculateNextBillingPeriod(stats.billingPeriodEnd) - newPeriodStart = nextPeriod.start - newPeriodEnd = nextPeriod.end - } else { - // Fallback to subscription start date or default calculation - const subscriptionStart = userSubscription[0]?.periodStart - const billingPeriod = calculateBillingPeriod(subscriptionStart || undefined) - newPeriodStart = billingPeriod.start - newPeriodEnd = billingPeriod.end - } - - // Archive current period cost and reset for new period (no longer updating period dates in user_stats) - await db - .update(userStats) - .set({ - lastPeriodCost: currentPeriodCost, - currentPeriodCost: '0', - }) - .where(eq(userStats.userId, userId)) - - logger.info('Reset billing period for user', { - userId, - archivedAmount: currentPeriodCost, - newPeriodStart, - newPeriodEnd, - basedOnSubscription: !!userSubscription[0]?.periodEnd, - }) - } catch (error) { - logger.error('Failed to reset user billing period', { userId, error }) - throw error - } -} - -/** - * Reset billing period for all members of an organization - */ -export async function resetOrganizationBillingPeriod(organizationId: string): Promise { - 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 deleted file mode 100644 index 3f8a8424e..000000000 --- a/apps/sim/lib/billing/core/billing.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - getPlanPricing, - getUsersAndOrganizationsForOverageBilling, -} from '@/lib/billing/core/billing' -import { - calculateBillingPeriod, - calculateNextBillingPeriod, -} from '@/lib/billing/core/billing-periods' - -vi.mock('@/db', () => ({ - db: { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([]), - }, -})) - -vi.mock('@/lib/logs/console/logger', () => ({ - createLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), -})) - -vi.mock('@/lib/billing/core/subscription', () => ({ - getHighestPrioritySubscription: vi.fn(), -})) - -vi.mock('@/lib/billing/core/usage', () => ({ - getUserUsageData: vi.fn(), -})) - -vi.mock('@/lib/billing/core/stripe-client', () => ({ - getStripeClient: vi.fn().mockReturnValue(null), - requireStripeClient: vi.fn().mockImplementation(() => { - throw new Error( - 'Stripe client is not available. Set STRIPE_SECRET_KEY in your environment variables.' - ) - }), - hasValidStripeCredentials: vi.fn().mockReturnValue(false), -})) - -describe('Billing Core Functions', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.setSystemTime(new Date('2024-07-06T10:00:00Z')) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - describe('calculateBillingPeriod', () => { - it.concurrent('calculates billing period from subscription dates correctly', () => { - vi.setSystemTime(new Date('2024-07-06T10:00:00Z')) - const subscriptionStart = new Date('2024-01-15T00:00:00Z') - const subscriptionEnd = new Date('2024-08-15T00:00:00Z') - const result = calculateBillingPeriod(subscriptionStart, subscriptionEnd) - - // Should return the current subscription period since we're within it - expect(result.start).toEqual(subscriptionStart) - expect(result.end).toEqual(subscriptionEnd) - expect(result.start.getUTCDate()).toBe(15) // Should preserve day from subscription - expect(result.end.getUTCDate()).toBe(15) - }) - - it.concurrent('calculates next period when current subscription period has ended', () => { - vi.setSystemTime(new Date('2024-08-20T10:00:00Z')) // After subscription end - const subscriptionStart = new Date('2024-01-15T00:00:00Z') - const subscriptionEnd = new Date('2024-08-15T00:00:00Z') // Already ended - const result = calculateBillingPeriod(subscriptionStart, subscriptionEnd) - - // Should calculate next period starting from subscription end - expect(result.start).toEqual(subscriptionEnd) - expect(result.end.getUTCFullYear()).toBe(2024) - expect(result.end.getUTCMonth()).toBe(8) // September (0-indexed) - expect(result.end.getUTCDate()).toBe(15) // Should preserve day - }) - - it.concurrent('returns current month when no subscription date provided', () => { - vi.setSystemTime(new Date('2024-07-15T10:00:00Z')) - const result = calculateBillingPeriod() - - // Should return current calendar month (1st to last day of current month) - expect(result.start.getUTCFullYear()).toBe(2024) - expect(result.start.getUTCMonth()).toBe(6) // July (0-indexed) - expect(result.start.getUTCDate()).toBe(1) // Should start on 1st of month - expect(result.end.getUTCFullYear()).toBe(2024) - expect(result.end.getUTCMonth()).toBe(6) // July (0-indexed) - ends on last day of current month - expect(result.end.getUTCDate()).toBe(31) // Should end on last day of July - expect(result.end.getUTCHours()).toBe(23) // Should end at 23:59:59.999 - expect(result.end.getUTCMinutes()).toBe(59) - expect(result.end.getUTCSeconds()).toBe(59) - }) - - it.concurrent('handles subscription anniversary date correctly', () => { - vi.setSystemTime(new Date('2024-07-06T10:00:00Z')) - const subscriptionStart = new Date('2024-01-15T00:00:00Z') - const subscriptionEnd = new Date('2024-07-15T00:00:00Z') - const result = calculateBillingPeriod(subscriptionStart, subscriptionEnd) - - // Should maintain the 15th as billing day - expect(result.start.getUTCDate()).toBe(15) - expect(result.end.getUTCDate()).toBe(15) - - // Current period should contain the current date (July 6) - const currentDate = new Date('2024-07-06T10:00:00Z') - expect(currentDate.getTime()).toBeGreaterThanOrEqual(result.start.getTime()) - expect(currentDate.getTime()).toBeLessThan(result.end.getTime()) - }) - }) - - describe('calculateNextBillingPeriod', () => { - it.concurrent('calculates next period correctly', () => { - const currentPeriodEnd = new Date('2024-07-15T23:59:59Z') - const result = calculateNextBillingPeriod(currentPeriodEnd) - - expect(result.start.getUTCDate()).toBe(15) - expect(result.start.getUTCMonth()).toBe(6) // July (0-indexed) - expect(result.end.getUTCDate()).toBe(15) - expect(result.end.getUTCMonth()).toBe(7) // August (0-indexed) - }) - - it.concurrent('handles month boundary correctly', () => { - const currentPeriodEnd = new Date('2024-01-31T23:59:59Z') - const result = calculateNextBillingPeriod(currentPeriodEnd) - - expect(result.start.getUTCMonth()).toBe(0) // January - expect(result.end.getUTCMonth()).toBeGreaterThanOrEqual(1) // February or later due to month overflow - }) - }) - - describe('getPlanPricing', () => { - it.concurrent('returns correct pricing for free plan', () => { - const result = getPlanPricing('free') - expect(result).toEqual({ basePrice: 0, minimum: 0 }) - }) - - it.concurrent('returns correct pricing for pro plan', () => { - const result = getPlanPricing('pro') - expect(result).toEqual({ basePrice: 20, minimum: 20 }) - }) - - it.concurrent('returns correct pricing for team plan', () => { - const result = getPlanPricing('team') - expect(result).toEqual({ basePrice: 40, minimum: 40 }) - }) - - it.concurrent('returns correct pricing for enterprise plan with metadata', () => { - const subscription = { - metadata: { perSeatAllowance: 150 }, - } - const result = getPlanPricing('enterprise', subscription) - expect(result).toEqual({ basePrice: 150, minimum: 150 }) - }) - - it.concurrent('handles invalid perSeatAllowance values - negative number', () => { - const subscription = { - metadata: { perSeatAllowance: -50 }, - } - const result = getPlanPricing('enterprise', subscription) - // Should fall back to default enterprise pricing - expect(result).toEqual({ basePrice: 100, minimum: 100 }) - }) - - it.concurrent('handles invalid perSeatAllowance values - zero', () => { - const subscription = { - metadata: { perSeatAllowance: 0 }, - } - const result = getPlanPricing('enterprise', subscription) - // Should fall back to default enterprise pricing - expect(result).toEqual({ basePrice: 100, minimum: 100 }) - }) - - it.concurrent('handles invalid perSeatAllowance values - non-numeric string', () => { - const subscription = { - metadata: { perSeatAllowance: 'invalid' }, - } - const result = getPlanPricing('enterprise', subscription) - // Should fall back to default enterprise pricing - expect(result).toEqual({ basePrice: 100, minimum: 100 }) - }) - - it.concurrent('handles invalid perSeatAllowance values - null', () => { - const subscription = { - metadata: { perSeatAllowance: null }, - } - const result = getPlanPricing('enterprise', subscription) - // Should fall back to default enterprise pricing - expect(result).toEqual({ basePrice: 100, minimum: 100 }) - }) - - it.concurrent('returns default enterprise pricing when metadata missing', () => { - const result = getPlanPricing('enterprise') - expect(result).toEqual({ basePrice: 100, minimum: 100 }) - }) - }) - - describe('getUsersAndOrganizationsForOverageBilling', () => { - it.concurrent('returns empty arrays when no subscriptions due', async () => { - const result = await getUsersAndOrganizationsForOverageBilling() - - expect(result).toHaveProperty('users') - expect(result).toHaveProperty('organizations') - expect(Array.isArray(result.users)).toBe(true) - expect(Array.isArray(result.organizations)).toBe(true) - }) - - it.concurrent('filters by current date correctly', async () => { - vi.setSystemTime(new Date('2024-07-15T10:00:00Z')) - - const result = await getUsersAndOrganizationsForOverageBilling() - - // Should only return entities whose billing period ends on July 15th - expect(result.users).toEqual([]) - expect(result.organizations).toEqual([]) - }) - }) - - describe('Stripe client integration', () => { - it.concurrent('does not fail when Stripe credentials are not available', async () => { - const result = await getUsersAndOrganizationsForOverageBilling() - - expect(result).toHaveProperty('users') - expect(result).toHaveProperty('organizations') - }) - }) - - describe('Date handling edge cases', () => { - it.concurrent('handles month boundaries correctly', () => { - // Test end of January (28/29 days) to February - const janEnd = new Date('2024-01-31T00:00:00Z') - const result = calculateNextBillingPeriod(janEnd) - - expect(result.start.getUTCMonth()).toBe(0) // January - expect(result.end.getUTCMonth()).toBeGreaterThanOrEqual(1) // February or later due to month overflow - }) - - it.concurrent('handles leap year correctly', () => { - const febEnd = new Date('2024-02-29T00:00:00Z') - const result = calculateNextBillingPeriod(febEnd) - - expect(result.start.getUTCFullYear()).toBe(2024) - expect(result.start.getUTCMonth()).toBe(1) - expect(result.start.getUTCDate()).toBe(29) - expect(result.end.getUTCFullYear()).toBe(2024) - expect(result.end.getUTCMonth()).toBe(2) - expect(result.end.getUTCDate()).toBe(29) - }) - - it.concurrent('handles year boundary correctly', () => { - const decEnd = new Date('2024-12-15T00:00:00Z') - const result = calculateNextBillingPeriod(decEnd) - - expect(result.start.getUTCFullYear()).toBe(2024) - expect(result.start.getUTCMonth()).toBe(11) // December - expect(result.end.getUTCFullYear()).toBe(2025) - expect(result.end.getUTCMonth()).toBe(0) // January - }) - - it.concurrent('basic date calculations work', () => { - const testDate = new Date('2024-07-15T00:00:00Z') - const result = calculateNextBillingPeriod(testDate) - - expect(result.start).toBeInstanceOf(Date) - expect(result.end).toBeInstanceOf(Date) - expect(result.end.getTime()).toBeGreaterThan(result.start.getTime()) - }) - }) -}) diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 0088843ae..0ff242211 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -1,15 +1,16 @@ import { and, eq } from 'drizzle-orm' -import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants' -import { - resetOrganizationBillingPeriod, - resetUserBillingPeriod, -} from '@/lib/billing/core/billing-periods' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getUserUsageData } from '@/lib/billing/core/usage' -import { requireStripeClient } from '@/lib/billing/stripe-client' +import { + getEnterpriseTierLimitPerSeat, + getFreeTierLimit, + getProTierLimit, + getTeamTierLimitPerSeat, +} from '@/lib/billing/subscriptions/utils' +import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' -import { member, organization, subscription, user } from '@/db/schema' +import { member, subscription, user } from '@/db/schema' const logger = createLogger('Billing') @@ -31,13 +32,6 @@ export async function getOrganizationSubscription(organizationId: string) { } } -interface BillingResult { - success: boolean - chargedAmount?: number - invoiceId?: string - error?: string -} - /** * BILLING MODEL: * 1. User purchases $20 Pro plan → Gets charged $20 immediately via Stripe subscription @@ -53,271 +47,35 @@ export function getPlanPricing( plan: string, subscription?: any ): { - basePrice: number // What they pay upfront via Stripe subscription - minimum: number // Minimum they're guaranteed to pay + basePrice: number // What they pay upfront via Stripe subscription (per seat for team/enterprise) } { switch (plan) { case 'free': - return { basePrice: 0, minimum: 0 } // Free plan has no charges + return { basePrice: 0 } // Free plan has no charges case 'pro': - return { basePrice: 20, minimum: 20 } // $20/month subscription + return { basePrice: getProTierLimit() } case 'team': - return { basePrice: 40, minimum: 40 } // $40/seat/month subscription + return { basePrice: getTeamTierLimitPerSeat() } case 'enterprise': - // Get per-seat pricing from metadata + // Enterprise uses per-seat pricing like Team plans + // Custom per-seat price can be set in metadata if (subscription?.metadata) { - const metadata = + const metadata: EnterpriseSubscriptionMetadata = typeof subscription.metadata === 'string' ? JSON.parse(subscription.metadata) : subscription.metadata - // Validate perSeatAllowance is a positive number - const perSeatAllowance = metadata.perSeatAllowance - const perSeatPrice = - typeof perSeatAllowance === 'number' && perSeatAllowance > 0 ? perSeatAllowance : 100 // Fall back to default for invalid values - - return { basePrice: perSeatPrice, minimum: perSeatPrice } - } - return { basePrice: 100, minimum: 100 } // Default enterprise pricing - default: - return { basePrice: 0, minimum: 0 } - } -} - -/** - * Get Stripe customer ID for a user or organization - */ -async function getStripeCustomerId(referenceId: string): Promise { - try { - // First check if it's a user - const userRecord = await db - .select({ stripeCustomerId: user.stripeCustomerId }) - .from(user) - .where(eq(user.id, referenceId)) - .limit(1) - - if (userRecord.length > 0 && userRecord[0].stripeCustomerId) { - return userRecord[0].stripeCustomerId - } - - // Check if it's an organization - const orgRecord = await db - .select({ metadata: organization.metadata }) - .from(organization) - .where(eq(organization.id, referenceId)) - .limit(1) - - if (orgRecord.length > 0) { - // First, check if organization has its own Stripe customer (legacy support) - if (orgRecord[0].metadata) { - const metadata = - typeof orgRecord[0].metadata === 'string' - ? JSON.parse(orgRecord[0].metadata) - : orgRecord[0].metadata - - if (metadata?.stripeCustomerId) { - return metadata.stripeCustomerId + const perSeatPrice = metadata.perSeatPrice + ? Number.parseFloat(String(metadata.perSeatPrice)) + : undefined + if (perSeatPrice && perSeatPrice > 0 && !Number.isNaN(perSeatPrice)) { + return { basePrice: perSeatPrice } } } - - // If organization has no Stripe customer, use the owner's customer - // This is our new pattern: subscriptions stay with user, referenceId = orgId - const ownerRecord = await db - .select({ - stripeCustomerId: user.stripeCustomerId, - userId: user.id, - }) - .from(user) - .innerJoin(member, eq(member.userId, user.id)) - .where(and(eq(member.organizationId, referenceId), eq(member.role, 'owner'))) - .limit(1) - - if (ownerRecord.length > 0 && ownerRecord[0].stripeCustomerId) { - logger.debug('Using organization owner Stripe customer for billing', { - organizationId: referenceId, - ownerId: ownerRecord[0].userId, - stripeCustomerId: ownerRecord[0].stripeCustomerId, - }) - return ownerRecord[0].stripeCustomerId - } - - logger.warn('No Stripe customer found for organization or its owner', { - organizationId: referenceId, - }) - } - - return null - } catch (error) { - logger.error('Failed to get Stripe customer ID', { referenceId, error }) - return null - } -} - -/** - * Create a Stripe invoice for overage billing only - */ -export async function createOverageBillingInvoice( - customerId: string, - overageAmount: number, - description: string, - metadata: Record = {} -): 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', - } + // Default enterprise per-seat pricing + return { basePrice: getEnterpriseTierLimitPerSeat() } + default: + return { basePrice: 0 } } } @@ -363,374 +121,6 @@ export async function calculateUserOverage(userId: string): Promise<{ } } -/** - * Process overage billing for an individual user - */ -export async function processUserOverageBilling(userId: string): Promise { - 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 directly (referenceId = organizationId) - const subscription = await getOrganizationSubscription(organizationId) - - if (!subscription || !['team', 'enterprise'].includes(subscription.plan)) { - logger.warn('No team/enterprise subscription found for organization', { organizationId }) - return { success: false, error: 'No valid subscription found' } - } - - // Get organization's Stripe customer ID - const stripeCustomerId = await getStripeCustomerId(organizationId) - if (!stripeCustomerId) { - logger.error('No Stripe customer ID found for organization', { organizationId }) - return { success: false, error: 'No Stripe customer ID found' } - } - - // Get organization owner's email for billing - const orgOwner = await db - .select({ - userId: member.userId, - userEmail: user.email, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) - .limit(1) - - if (orgOwner[0]?.userEmail) { - // Update Stripe customer with organization owner's email - const stripeClient = requireStripeClient() - try { - await stripeClient.customers.update(stripeCustomerId, { - email: orgOwner[0].userEmail, - }) - logger.info('Updated Stripe customer with organization owner email', { - organizationId, - stripeCustomerId, - email: orgOwner[0].userEmail, - }) - } catch (updateError) { - logger.warn('Failed to update Stripe customer email for organization', { - organizationId, - stripeCustomerId, - error: updateError, - }) - } - } - - // Get all organization members - const members = await db - .select({ - userId: member.userId, - userName: user.name, - userEmail: user.email, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(eq(member.organizationId, organizationId)) - - if (members.length === 0) { - logger.info('No members found for organization overage billing', { organizationId }) - return { success: true, chargedAmount: 0 } - } - - // Calculate total team usage across all members - const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan, subscription) - const licensedSeats = subscription.seats || 1 - const baseSubscriptionAmount = licensedSeats * basePricePerSeat // What Stripe already charged - - let totalTeamUsage = 0 - const memberUsageDetails = [] - - for (const memberInfo of members) { - const usageData = await getUserUsageData(memberInfo.userId) - totalTeamUsage += usageData.currentUsage - - memberUsageDetails.push({ - userId: memberInfo.userId, - name: memberInfo.userName, - email: memberInfo.userEmail, - usage: usageData.currentUsage, - }) - } - - // Calculate team-level overage: total usage beyond what was already paid to Stripe - const totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount) - - // Skip if no overage across the organization - if (totalOverage <= 0) { - logger.info('No overage to bill for organization', { - organizationId, - licensedSeats, - memberCount: members.length, - totalTeamUsage, - baseSubscriptionAmount, - }) - - // Still reset billing period for all members - try { - await resetOrganizationBillingPeriod(organizationId) - } catch (resetError) { - logger.error('Failed to reset organization billing period', { - organizationId, - error: resetError, - }) - } - - return { success: true, chargedAmount: 0 } - } - - // Create consolidated overage invoice for the organization - const description = `Team usage overage for ${subscription.plan} plan - ${licensedSeats} licensed seats, $${totalTeamUsage.toFixed(2)} total usage, $${totalOverage.toFixed(2)} overage` - const metadata = { - organizationId, - plan: subscription.plan, - licensedSeats: licensedSeats.toString(), - memberCount: members.length.toString(), - basePricePerSeat: basePricePerSeat.toString(), - baseSubscriptionAmount: baseSubscriptionAmount.toString(), - totalTeamUsage: totalTeamUsage.toString(), - totalOverage: totalOverage.toString(), - billingPeriod: new Date().toISOString().slice(0, 7), // YYYY-MM format - memberDetails: JSON.stringify(memberUsageDetails), - } - - const result = await createOverageBillingInvoice( - stripeCustomerId, - totalOverage, - description, - metadata - ) - - // If billing was successful, reset billing period for all organization members - if (result.success) { - try { - await resetOrganizationBillingPeriod(organizationId) - logger.info('Successfully reset billing period for organization after overage billing', { - organizationId, - memberCount: members.length, - }) - } catch (resetError) { - logger.error( - 'Failed to reset organization billing period after successful overage charge', - { - organizationId, - error: resetError, - } - ) - } - } - - logger.info('Processed organization overage billing', { - organizationId, - memberCount: members.length, - totalOverage, - result, - }) - - return result - } catch (error) { - logger.error('Failed to process organization overage billing', { organizationId, error }) - return { success: false, error: 'Failed to process organization overage billing' } - } -} - -/** - * Get users and organizations whose billing periods end today - */ -export async function getUsersAndOrganizationsForOverageBilling(): Promise<{ - users: string[] - organizations: string[] -}> { - try { - const today = new Date() - today.setUTCHours(0, 0, 0, 0) // Start of today - const tomorrow = new Date(today) - tomorrow.setUTCDate(tomorrow.getUTCDate() + 1) // Start of tomorrow - - logger.info('Checking for subscriptions with billing periods ending today', { - today: today.toISOString(), - tomorrow: tomorrow.toISOString(), - }) - - // Get all active subscriptions (excluding free plans) - const activeSubscriptions = await db - .select() - .from(subscription) - .where(eq(subscription.status, 'active')) - - const users: string[] = [] - const organizations: string[] = [] - - for (const sub of activeSubscriptions) { - if (sub.plan === 'free') { - continue // Skip free plans - } - - // Check if subscription period ends today (range-based, inclusive of day) - let shouldBillToday = false - - if (sub.periodEnd) { - const periodEnd = new Date(sub.periodEnd) - const endsToday = periodEnd >= today && periodEnd < tomorrow - - if (endsToday) { - shouldBillToday = true - logger.info('Subscription period ends today', { - referenceId: sub.referenceId, - plan: sub.plan, - periodEnd: sub.periodEnd, - }) - } - } - - if (shouldBillToday) { - // Check if referenceId is a user or organization - const userExists = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.id, sub.referenceId)) - .limit(1) - - if (userExists.length > 0) { - // It's a user subscription (pro plan) - users.push(sub.referenceId) - } else { - // Check if it's an organization - const orgExists = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, sub.referenceId)) - .limit(1) - - if (orgExists.length > 0) { - // It's an organization subscription (team/enterprise) - organizations.push(sub.referenceId) - } - } - } - } - - logger.info('Found entities for daily billing check', { - userCount: users.length, - organizationCount: organizations.length, - users, - organizations, - }) - - return { users, organizations } - } catch (error) { - logger.error('Failed to get entities for daily billing check', { error }) - return { users: [], organizations: [] } - } -} - /** * Get comprehensive billing and subscription summary */ @@ -773,6 +163,7 @@ export async function getSimplifiedBillingSummary( } organizationData?: { seatCount: number + memberCount: number totalBasePrice: number totalCurrentUsage: number totalOverage: number @@ -807,8 +198,9 @@ export async function getSimplifiedBillingSummary( .where(eq(member.organizationId, organizationId)) const { basePrice: basePricePerSeat } = getPlanPricing(subscription.plan, subscription) + // Use licensed seats from Stripe as source of truth const licensedSeats = subscription.seats || 1 - const totalBasePrice = basePricePerSeat * licensedSeats // Based on licensed seats, not member count + const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription let totalCurrentUsage = 0 @@ -869,6 +261,7 @@ export async function getSimplifiedBillingSummary( }, organizationData: { seatCount: licensedSeats, + memberCount: members.length, totalBasePrice, totalCurrentUsage, totalOverage, @@ -878,9 +271,26 @@ export async function getSimplifiedBillingSummary( // Individual billing summary const { basePrice } = getPlanPricing(plan, subscription) - const overageAmount = Math.max(0, usageData.currentUsage - basePrice) - const percentUsed = - usageData.limit > 0 ? Math.round((usageData.currentUsage / usageData.limit) * 100) : 0 + + // For team and enterprise plans, calculate total team usage instead of individual usage + let currentUsage = usageData.currentUsage + if ((isTeam || isEnterprise) && subscription?.referenceId) { + // Get all team members and sum their usage + const teamMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, subscription.referenceId)) + + let totalTeamUsage = 0 + for (const teamMember of teamMembers) { + const memberUsageData = await getUserUsageData(teamMember.userId) + totalTeamUsage += memberUsageData.currentUsage + } + currentUsage = totalTeamUsage + } + + const overageAmount = Math.max(0, currentUsage - basePrice) + const percentUsed = usageData.limit > 0 ? Math.round((currentUsage / usageData.limit) * 100) : 0 // Calculate days remaining in billing period const daysRemaining = usageData.billingPeriodEnd @@ -894,13 +304,13 @@ export async function getSimplifiedBillingSummary( type: 'individual', plan, basePrice, - currentUsage: usageData.currentUsage, + currentUsage: currentUsage, overageAmount, totalProjected: basePrice + overageAmount, usageLimit: usageData.limit, percentUsed, isWarning: percentUsed >= 80 && percentUsed < 100, - isExceeded: usageData.currentUsage >= usageData.limit, + isExceeded: currentUsage >= usageData.limit, daysRemaining, // Subscription details isPaid, @@ -914,11 +324,11 @@ export async function getSimplifiedBillingSummary( periodEnd: subscription?.periodEnd || null, // Usage details usage: { - current: usageData.currentUsage, + current: currentUsage, limit: usageData.limit, percentUsed, isWarning: percentUsed >= 80 && percentUsed < 100, - isExceeded: usageData.currentUsage >= usageData.limit, + isExceeded: currentUsage >= usageData.limit, billingPeriodStart: usageData.billingPeriodStart, billingPeriodEnd: usageData.billingPeriodEnd, lastPeriodCost: usageData.lastPeriodCost, @@ -942,7 +352,7 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') { currentUsage: 0, overageAmount: 0, totalProjected: 0, - usageLimit: DEFAULT_FREE_CREDITS, + usageLimit: getFreeTierLimit(), percentUsed: 0, isWarning: false, isExceeded: false, @@ -960,7 +370,7 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') { // Usage details usage: { current: 0, - limit: DEFAULT_FREE_CREDITS, + limit: getFreeTierLimit(), percentUsed: 0, isWarning: false, isExceeded: false, @@ -971,111 +381,3 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') { }, } } - -/** - * Process daily billing check for users and organizations with periods ending today - */ -export async function processDailyBillingCheck(): Promise<{ - success: boolean - processedUsers: number - processedOrganizations: number - totalChargedAmount: number - errors: string[] -}> { - try { - logger.info('Starting daily billing check process') - - const { users, organizations } = await getUsersAndOrganizationsForOverageBilling() - - let processedUsers = 0 - let processedOrganizations = 0 - let totalChargedAmount = 0 - const errors: string[] = [] - - // Process individual users (pro plans) - for (const userId of users) { - try { - const result = await processUserOverageBilling(userId) - if (result.success) { - processedUsers++ - totalChargedAmount += result.chargedAmount || 0 - logger.info('Successfully processed user overage billing', { - userId, - chargedAmount: result.chargedAmount, - }) - } else { - errors.push(`User ${userId}: ${result.error}`) - logger.error('Failed to process user overage billing', { userId, error: result.error }) - } - } catch (error) { - const errorMsg = `User ${userId}: ${error instanceof Error ? error.message : 'Unknown error'}` - errors.push(errorMsg) - logger.error('Exception during user overage billing', { userId, error }) - } - } - - // Process organizations (team/enterprise plans) - for (const organizationId of organizations) { - try { - const result = await processOrganizationOverageBilling(organizationId) - if (result.success) { - processedOrganizations++ - totalChargedAmount += result.chargedAmount || 0 - logger.info('Successfully processed organization overage billing', { - organizationId, - chargedAmount: result.chargedAmount, - }) - } else { - errors.push(`Organization ${organizationId}: ${result.error}`) - logger.error('Failed to process organization overage billing', { - organizationId, - error: result.error, - }) - } - } catch (error) { - const errorMsg = `Organization ${organizationId}: ${error instanceof Error ? error.message : 'Unknown error'}` - errors.push(errorMsg) - logger.error('Exception during organization overage billing', { organizationId, error }) - } - } - - logger.info('Completed daily billing check process', { - processedUsers, - processedOrganizations, - totalChargedAmount, - errorCount: errors.length, - }) - - return { - success: errors.length === 0, - processedUsers, - processedOrganizations, - totalChargedAmount, - errors, - } - } catch (error) { - logger.error('Fatal error during daily billing check process', { error }) - return { - success: false, - processedUsers: 0, - processedOrganizations: 0, - totalChargedAmount: 0, - errors: [error instanceof Error ? error.message : 'Fatal daily billing check process error'], - } - } -} - -/** - * Legacy function for backward compatibility - now redirects to daily billing check - * @deprecated Use processDailyBillingCheck instead - */ -export async function processMonthlyOverageBilling(): Promise<{ - success: boolean - processedUsers: number - processedOrganizations: number - totalChargedAmount: number - errors: string[] -}> { - logger.warn('processMonthlyOverageBilling is deprecated, use processDailyBillingCheck instead') - return processDailyBillingCheck() -} diff --git a/apps/sim/lib/billing/core/organization-billing.ts b/apps/sim/lib/billing/core/organization-billing.ts index 7294ca20d..fcb981128 100644 --- a/apps/sim/lib/billing/core/organization-billing.ts +++ b/apps/sim/lib/billing/core/organization-billing.ts @@ -1,6 +1,6 @@ import { and, eq } from 'drizzle-orm' -import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants' import { getPlanPricing } from '@/lib/billing/core/billing' +import { getFreeTierLimit } from '@/lib/billing/subscriptions/utils' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' import { member, organization, subscription, user, userStats } from '@/db/schema' @@ -26,6 +26,10 @@ async function getOrganizationSubscription(organizationId: string) { } } +function roundCurrency(value: number): number { + return Math.round(value * 100) / 100 +} + interface OrganizationUsageData { organizationId: string organizationName: string @@ -33,8 +37,10 @@ interface OrganizationUsageData { subscriptionStatus: string totalSeats: number usedSeats: number + seatsCount: number totalCurrentUsage: number totalUsageLimit: number + minimumBillingAmount: number averageUsagePerMember: number billingPeriodStart: Date | null billingPeriodEnd: Date | null @@ -104,7 +110,7 @@ export async function getOrganizationBillingData( // Process member data const members: MemberUsageData[] = membersWithUsage.map((memberRecord) => { const currentUsage = Number(memberRecord.currentPeriodCost || 0) - const usageLimit = Number(memberRecord.currentUsageLimit || DEFAULT_FREE_CREDITS) + const usageLimit = Number(memberRecord.currentUsageLimit || getFreeTierLimit()) const percentUsed = usageLimit > 0 ? (currentUsage / usageLimit) * 100 : 0 return { @@ -126,26 +132,33 @@ export async function getOrganizationBillingData( // Get per-seat pricing for the plan const { basePrice: pricePerSeat } = getPlanPricing(subscription.plan, subscription) - const licensedSeats = subscription.seats || members.length + + // Use Stripe subscription seats as source of truth + // Ensure we always have at least 1 seat (protect against 0 or falsy values) + const licensedSeats = Math.max(subscription.seats || 1, 1) // Validate seat capacity - warn if members exceed licensed seats - if (subscription.seats && members.length > subscription.seats) { + if (members.length > licensedSeats) { logger.warn('Organization has more members than licensed seats', { organizationId, - licensedSeats: subscription.seats, + licensedSeats, actualMembers: members.length, plan: subscription.plan, }) } - // Billing is based on licensed seats, not actual member count + // Billing is based on licensed seats from Stripe, not actual member count // This ensures organizations pay for their seat capacity regardless of utilization - const seatsCount = licensedSeats - const minimumBillingAmount = seatsCount * pricePerSeat + const minimumBillingAmount = licensedSeats * pricePerSeat - // Total usage limit represents the minimum amount the team will be billed - // This is based on licensed seats, not individual member limits (which are personal controls) - const totalUsageLimit = minimumBillingAmount + // Total usage limit: never below the minimum based on licensed seats + const configuredLimit = organizationData.orgUsageLimit + ? Number.parseFloat(organizationData.orgUsageLimit) + : null + const totalUsageLimit = + configuredLimit !== null + ? Math.max(configuredLimit, minimumBillingAmount) + : minimumBillingAmount const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0 @@ -155,14 +168,16 @@ export async function getOrganizationBillingData( return { organizationId, - organizationName: organizationData.name, + organizationName: organizationData.name || '', subscriptionPlan: subscription.plan, - subscriptionStatus: subscription.status || 'active', - totalSeats: subscription.seats || 1, + subscriptionStatus: subscription.status || 'inactive', + totalSeats: Math.max(subscription.seats || 1, 1), usedSeats: members.length, - totalCurrentUsage: Math.round(totalCurrentUsage * 100) / 100, - totalUsageLimit: Math.round(totalUsageLimit * 100) / 100, - averageUsagePerMember: Math.round(averageUsagePerMember * 100) / 100, + seatsCount: licensedSeats, + totalCurrentUsage: roundCurrency(totalCurrentUsage), + totalUsageLimit: roundCurrency(totalUsageLimit), + minimumBillingAmount: roundCurrency(minimumBillingAmount), + averageUsagePerMember: roundCurrency(averageUsagePerMember), billingPeriodStart, billingPeriodEnd, members: members.sort((a, b) => b.currentUsage - a.currentUsage), // Sort by usage desc @@ -174,98 +189,69 @@ export async function getOrganizationBillingData( } /** - * Update usage limit for a specific organization member + * Update organization usage limit (cap) */ -export async function updateMemberUsageLimit( +export async function updateOrganizationUsageLimit( organizationId: string, - memberId: string, - newLimit: number, - adminUserId: string -): Promise { + newLimit: number +): Promise<{ success: boolean; error?: string }> { try { - // Verify admin has permission to modify limits - const adminMemberRecord = await db + // Validate the organization exists + const orgRecord = await db .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, adminUserId))) + .from(organization) + .where(eq(organization.id, organizationId)) .limit(1) - if (adminMemberRecord.length === 0 || !['owner', 'admin'].includes(adminMemberRecord[0].role)) { - throw new Error('Insufficient permissions to modify usage limits') + if (orgRecord.length === 0) { + return { success: false, error: 'Organization not found' } } - // Verify member exists in organization - const targetMemberRecord = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId))) - .limit(1) - - if (targetMemberRecord.length === 0) { - throw new Error('Member not found in organization') - } - - // Get organization subscription to validate limit + // Get subscription to validate minimum const subscription = await getOrganizationSubscription(organizationId) if (!subscription) { - throw new Error('No active subscription found') + return { success: false, error: 'No active subscription found' } } - // Validate minimum limit based on plan - const planLimits = { - free: DEFAULT_FREE_CREDITS, - pro: 20, - team: 40, - enterprise: 100, // Default, can be overridden by metadata - } + // Calculate minimum based on seats + const { basePrice } = getPlanPricing(subscription.plan, subscription) + const minimumLimit = Math.max(subscription.seats || 1, 1) * basePrice - let minimumLimit = - planLimits[subscription.plan as keyof typeof planLimits] || DEFAULT_FREE_CREDITS - - // For enterprise, check metadata for custom limits - if (subscription.plan === 'enterprise' && subscription.metadata) { - try { - const metadata = - typeof subscription.metadata === 'string' - ? JSON.parse(subscription.metadata) - : subscription.metadata - if (metadata.perSeatAllowance) { - minimumLimit = metadata.perSeatAllowance - } - } catch (e) { - logger.warn('Failed to parse subscription metadata', { error: e }) + // Validate new limit is not below minimum + if (newLimit < minimumLimit) { + return { + success: false, + error: `Usage limit cannot be less than minimum billing amount of $${roundCurrency(minimumLimit).toFixed(2)}`, } } - if (newLimit < minimumLimit) { - throw new Error(`Usage limit cannot be below $${minimumLimit} for ${subscription.plan} plan`) - } - - // Update the member's usage limit + // Update the organization usage limit + // Convert number to string for decimal column await db - .update(userStats) + .update(organization) .set({ - currentUsageLimit: newLimit.toString(), - usageLimitSetBy: adminUserId, - usageLimitUpdatedAt: new Date(), + orgUsageLimit: roundCurrency(newLimit).toFixed(2), + updatedAt: new Date(), }) - .where(eq(userStats.userId, memberId)) + .where(eq(organization.id, organizationId)) - logger.info('Updated member usage limit', { + logger.info('Organization usage limit updated', { organizationId, - memberId, newLimit, - adminUserId, + minimumLimit, }) + + return { success: true } } catch (error) { - logger.error('Failed to update member usage limit', { + logger.error('Failed to update organization usage limit', { organizationId, - memberId, newLimit, - adminUserId, error, }) - throw error + return { + success: false, + error: 'Failed to update usage limit', + } } } diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 369013031..221d892d8 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -1,11 +1,10 @@ import { and, eq, inArray } from 'drizzle-orm' -import { client } from '@/lib/auth-client' -import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants' import { - calculateDefaultUsageLimit, checkEnterprisePlan, checkProPlan, checkTeamPlan, + getFreeTierLimit, + getPerUserMinimumLimit, } from '@/lib/billing/subscriptions/utils' import type { UserSubscriptionState } from '@/lib/billing/types' import { isProd } from '@/lib/environment' @@ -157,9 +156,9 @@ export async function hasExceededCostLimit(userId: string): Promise { const subscription = await getHighestPrioritySubscription(userId) // Calculate usage limit - let limit = DEFAULT_FREE_CREDITS // Default free tier limit + let limit = getFreeTierLimit() // Default free tier limit if (subscription) { - limit = calculateDefaultUsageLimit(subscription) + limit = getPerUserMinimumLimit(subscription) logger.info('Using subscription-based limit', { userId, plan: subscription.plan, @@ -194,86 +193,7 @@ export async function hasExceededCostLimit(userId: string): Promise { /** * 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 - } -} +// Removed unused feature flag helpers: isSharingEnabled, isMultiplayerEnabled, isWorkspaceCollaborationEnabled /** * Get comprehensive subscription state for a user @@ -306,42 +226,12 @@ export async function getUserSubscriptionState(userId: string): Promise sub.status === 'active') - - sharingEnabled = !!activeSubscription?.limits?.sharingEnabled - multiplayerEnabled = !!activeSubscription?.limits?.multiplayerEnabled - workspaceCollaborationEnabled = - !!activeSubscription?.limits?.workspaceCollaborationEnabled - } catch (error) { - logger.error('Error checking subscription features', { error, userId }) - // Default to false on error - } - } - } - // Check cost limit using already-fetched user stats let hasExceededLimit = false if (isProd && statsRecords.length > 0) { - let limit = DEFAULT_FREE_CREDITS // Default free tier limit + let limit = getFreeTierLimit() // Default free tier limit if (subscription) { - limit = calculateDefaultUsageLimit(subscription) + limit = getPerUserMinimumLimit(subscription) } const currentCost = Number.parseFloat( @@ -356,11 +246,6 @@ export async function getUserSubscriptionState(userId: string): Promise { + try { + await db.insert(userStats).values({ + id: crypto.randomUUID(), + userId: userId, + currentUsageLimit: getFreeTierLimit().toString(), + usageLimitUpdatedAt: new Date(), + }) + + logger.info('User stats record created for new user', { userId }) + } catch (error) { + logger.error('Failed to create user stats record for new user', { + userId, + error, + }) + throw error + } +} /** * Get comprehensive usage data for a user */ export async function getUserUsageData(userId: string): Promise { try { - const userStatsData = await db - .select() - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) + const [userStatsData, subscription] = await Promise.all([ + db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1), + getHighestPrioritySubscription(userId), + ]) if (userStatsData.length === 0) { - // Initialize user stats if they don't exist - await initializeUserUsageLimit(userId) - return { - currentUsage: 0, - limit: DEFAULT_FREE_CREDITS, - percentUsed: 0, - isWarning: false, - isExceeded: false, - billingPeriodStart: null, - billingPeriodEnd: null, - lastPeriodCost: 0, - } + throw new Error(`User stats not found for userId: ${userId}`) } const stats = userStatsData[0] - const subscription = await getHighestPrioritySubscription(userId) const currentUsage = Number.parseFloat( stats.currentPeriodCost?.toString() ?? stats.totalCost.toString() ) - const limit = Number.parseFloat(stats.currentUsageLimit) - const percentUsed = limit > 0 ? Math.round((currentUsage / limit) * 100) : 0 + + // Determine usage limit based on plan type + let limit: number + + if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') { + // Free/Pro: Use individual user limit from userStats + limit = stats.currentUsageLimit + ? Number.parseFloat(stats.currentUsageLimit) + : getFreeTierLimit() + } else { + // Team/Enterprise: Use organization limit but never below minimum (seats × cost per seat) + const orgData = await db + .select({ orgUsageLimit: organization.orgUsageLimit }) + .from(organization) + .where(eq(organization.id, subscription.referenceId)) + .limit(1) + + const { getPlanPricing } = await import('@/lib/billing/core/billing') + const { basePrice } = getPlanPricing(subscription.plan, subscription) + const minimum = (subscription.seats || 1) * basePrice + + if (orgData.length > 0 && orgData[0].orgUsageLimit) { + const configured = Number.parseFloat(orgData[0].orgUsageLimit) + limit = Math.max(configured, minimum) + } else { + limit = minimum + } + } + + const percentUsed = limit > 0 ? Math.min(Math.floor((currentUsage / limit) * 100), 100) : 0 const isWarning = percentUsed >= 80 const isExceeded = currentUsage >= limit @@ -76,84 +112,56 @@ export async function getUserUsageData(userId: string): Promise { */ 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 = DEFAULT_FREE_CREDITS - } else if (subscription.plan === 'pro') { - // Pro plan users: $20 minimum - minimumLimit = 20 - } else if (subscription.plan === 'team') { - // Team plan users: $40 minimum (per-seat allocation, regardless of role) - minimumLimit = 40 - } else if (subscription.plan === 'enterprise') { - // Enterprise plan users: per-seat allocation from their plan - const metadata = subscription.metadata || {} - if (metadata.perSeatAllowance) { - minimumLimit = Number.parseFloat(metadata.perSeatAllowance) - } else if (metadata.totalAllowance) { - // For total allowance, use per-seat calculation - const seats = subscription.seats || 1 - minimumLimit = Number.parseFloat(metadata.totalAllowance) / seats - } else { - minimumLimit = 200 // Default enterprise per-seat limit - } - } else { - // Fallback to plan-based calculation - minimumLimit = calculateDefaultUsageLimit(subscription) - } - - const userStatsRecord = await db - .select() - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) + const [subscription, userStatsRecord] = await Promise.all([ + getHighestPrioritySubscription(userId), + db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1), + ]) if (userStatsRecord.length === 0) { - await initializeUserUsageLimit(userId) - return { - currentLimit: DEFAULT_FREE_CREDITS, - canEdit: false, - minimumLimit: DEFAULT_FREE_CREDITS, - plan: 'free', - setBy: null, - updatedAt: null, - } + throw new Error(`User stats not found for userId: ${userId}`) } const stats = userStatsRecord[0] + + // Determine limits based on plan type + let currentLimit: number + let minimumLimit: number + let canEdit: boolean + + if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') { + // Free/Pro: Use individual limits + currentLimit = stats.currentUsageLimit + ? Number.parseFloat(stats.currentUsageLimit) + : getFreeTierLimit() + minimumLimit = getPerUserMinimumLimit(subscription) + canEdit = canEditUsageLimit(subscription) + } else { + // Team/Enterprise: Use organization limits (users cannot edit) + const orgData = await db + .select({ orgUsageLimit: organization.orgUsageLimit }) + .from(organization) + .where(eq(organization.id, subscription.referenceId)) + .limit(1) + + const { getPlanPricing } = await import('@/lib/billing/core/billing') + const { basePrice } = getPlanPricing(subscription.plan, subscription) + const minimum = (subscription.seats || 1) * basePrice + + if (orgData.length > 0 && orgData[0].orgUsageLimit) { + const configured = Number.parseFloat(orgData[0].orgUsageLimit) + currentLimit = Math.max(configured, minimum) + } else { + currentLimit = minimum + } + minimumLimit = minimum + canEdit = false // Team/enterprise members cannot edit limits + } + return { - currentLimit: Number.parseFloat(stats.currentUsageLimit), + currentLimit, canEdit, minimumLimit, plan: subscription?.plan || 'free', - setBy: stats.usageLimitSetBy, updatedAt: stats.usageLimitUpdatedAt, } } catch (error) { @@ -166,32 +174,36 @@ export async function getUserUsageLimitInfo(userId: string): Promise { - try { - // Check if user already has usage stats - const existingStats = await db - .select() - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) + // Check if user already has usage stats + const existingStats = await db + .select() + .from(userStats) + .where(eq(userStats.userId, userId)) + .limit(1) - if (existingStats.length > 0) { - return // User already has usage stats, don't override - } - - // Create initial usage stats with default free credits limit - await db.insert(userStats).values({ - id: crypto.randomUUID(), - userId, - currentUsageLimit: DEFAULT_FREE_CREDITS.toString(), // Default free credits for new users - usageLimitUpdatedAt: new Date(), - billingPeriodStart: new Date(), // Start billing period immediately - }) - - logger.info('Initialized usage limit for new user', { userId, limit: DEFAULT_FREE_CREDITS }) - } catch (error) { - logger.error('Failed to initialize usage limit', { userId, error }) - throw error + if (existingStats.length > 0) { + return // User already has usage stats } + + // Check user's subscription to determine initial limit + const subscription = await getHighestPrioritySubscription(userId) + const isTeamOrEnterprise = + subscription && (subscription.plan === 'team' || subscription.plan === 'enterprise') + + // Create initial usage stats + await db.insert(userStats).values({ + id: crypto.randomUUID(), + userId, + // Team/enterprise: null (use org limit), Free/Pro: individual limit + currentUsageLimit: isTeamOrEnterprise ? null : getFreeTierLimit().toString(), + usageLimitUpdatedAt: new Date(), + }) + + logger.info('Initialized user stats', { + userId, + plan: subscription?.plan || 'free', + hasIndividualLimit: !isTeamOrEnterprise, + }) } /** @@ -205,64 +217,20 @@ export async function updateUserUsageLimit( try { const subscription = await getHighestPrioritySubscription(userId) - // Check if user can edit limits - let canEdit = canEditUsageLimit(subscription) - - if (subscription?.plan === 'team') { - // For team plans, the subscription referenceId should be the organization ID - // Check user's role in that organization - const orgMemberRecord = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, subscription.referenceId))) - .limit(1) - - if (orgMemberRecord.length > 0) { - const userRole = orgMemberRecord[0].role - // Team owners and admins can edit their own usage limits - // Regular team members cannot edit their own limits - canEdit = canEdit && ['owner', 'admin'].includes(userRole) - } else { - // User is not a member of the organization, should not be able to edit - canEdit = false + // Team/enterprise users don't have individual limits + if (subscription && (subscription.plan === 'team' || subscription.plan === 'enterprise')) { + return { + success: false, + error: 'Team and enterprise members use organization limits', } } - if (!canEdit) { - if (subscription?.plan === 'team') { - return { success: false, error: 'Only team owners and admins can edit usage limits' } - } + // Only pro users can edit limits (free users cannot) + if (!subscription || subscription.plan === 'free') { return { success: false, error: 'Free plan users cannot edit usage limits' } } - // Use plan-based minimums instead of role-based minimums - let minimumLimit: number - - if (!subscription || subscription.status !== 'active') { - // Free plan users (shouldn't reach here due to canEditUsageLimit check above) - minimumLimit = DEFAULT_FREE_CREDITS - } else if (subscription.plan === 'pro') { - // Pro plan users: $20 minimum - minimumLimit = 20 - } else if (subscription.plan === 'team') { - // Team plan users: $40 minimum (per-seat allocation, regardless of role) - minimumLimit = 40 - } else if (subscription.plan === 'enterprise') { - // Enterprise plan users: per-seat allocation from their plan - const metadata = subscription.metadata || {} - if (metadata.perSeatAllowance) { - minimumLimit = Number.parseFloat(metadata.perSeatAllowance) - } else if (metadata.totalAllowance) { - // For total allowance, use per-seat calculation - const seats = subscription.seats || 1 - minimumLimit = Number.parseFloat(metadata.totalAllowance) / seats - } else { - minimumLimit = 200 // Default enterprise per-seat limit - } - } else { - // Fallback to plan-based calculation - minimumLimit = calculateDefaultUsageLimit(subscription) - } + const minimumLimit = getPerUserMinimumLimit(subscription) logger.info('Applying plan-based validation', { userId, @@ -305,7 +273,6 @@ export async function updateUserUsageLimit( .update(userStats) .set({ currentUsageLimit: newLimit.toString(), - usageLimitSetBy: setBy || userId, usageLimitUpdatedAt: new Date(), }) .where(eq(userStats.userId, userId)) @@ -326,27 +293,57 @@ export async function updateUserUsageLimit( } /** - * Get usage limit for a user (simple version) + * Get usage limit for a user (used by checkUsageStatus for server-side checks) + * Free/Pro: Individual user limit from userStats + * Team/Enterprise: Organization limit */ export async function getUserUsageLimit(userId: string): Promise { - try { + const subscription = await getHighestPrioritySubscription(userId) + + if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') { + // Free/Pro: Use individual limit from userStats const userStatsQuery = await db - .select() + .select({ currentUsageLimit: userStats.currentUsageLimit }) .from(userStats) .where(eq(userStats.userId, userId)) .limit(1) if (userStatsQuery.length === 0) { - // User doesn't have stats yet, initialize and return default - await initializeUserUsageLimit(userId) - return DEFAULT_FREE_CREDITS // Default free plan limit + throw new Error(`User stats not found for userId: ${userId}`) + } + + // Individual limits should never be null for free/pro users + if (!userStatsQuery[0].currentUsageLimit) { + throw new Error( + `Invalid null usage limit for ${subscription?.plan || 'free'} user: ${userId}` + ) } return Number.parseFloat(userStatsQuery[0].currentUsageLimit) - } catch (error) { - logger.error('Failed to get user usage limit', { userId, error }) - return 5 // Fallback to safe default } + // Team/Enterprise: Use organization limit but never below minimum + const orgData = await db + .select({ orgUsageLimit: organization.orgUsageLimit }) + .from(organization) + .where(eq(organization.id, subscription.referenceId)) + .limit(1) + + if (orgData.length === 0) { + throw new Error(`Organization not found: ${subscription.referenceId}`) + } + + if (orgData[0].orgUsageLimit) { + const configured = Number.parseFloat(orgData[0].orgUsageLimit) + const { getPlanPricing } = await import('@/lib/billing/core/billing') + const { basePrice } = getPlanPricing(subscription.plan, subscription) + const minimum = (subscription.seats || 1) * basePrice + return Math.max(configured, minimum) + } + + // If org hasn't set a custom limit, use minimum (seats × cost per seat) + const { getPlanPricing } = await import('@/lib/billing/core/billing') + const { basePrice } = getPlanPricing(subscription.plan, subscription) + return (subscription.seats || 1) * basePrice } /** @@ -380,65 +377,68 @@ export async function checkUsageStatus(userId: string): Promise<{ * Sync usage limits based on subscription changes */ export async function syncUsageLimitsFromSubscription(userId: string): Promise { - try { - const subscription = await getHighestPrioritySubscription(userId) - const defaultLimit = calculateDefaultUsageLimit(subscription) + const [subscription, currentUserStats] = await Promise.all([ + getHighestPrioritySubscription(userId), + db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1), + ]) - // Get current user stats - const currentUserStats = await db - .select() - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) + if (currentUserStats.length === 0) { + throw new Error(`User stats not found for userId: ${userId}`) + } - if (currentUserStats.length === 0) { - // Create new user stats with default limit - await db.insert(userStats).values({ - id: crypto.randomUUID(), + const currentStats = currentUserStats[0] + + // Team/enterprise: Should have null individual limits + if (subscription && (subscription.plan === 'team' || subscription.plan === 'enterprise')) { + if (currentStats.currentUsageLimit !== null) { + await db + .update(userStats) + .set({ + currentUsageLimit: null, + usageLimitUpdatedAt: new Date(), + }) + .where(eq(userStats.userId, userId)) + + logger.info('Cleared individual limit for team/enterprise member', { userId, + plan: subscription.plan, + }) + } + return + } + + // Free/Pro: Handle individual limits + const defaultLimit = getPerUserMinimumLimit(subscription) + const currentLimit = currentStats.currentUsageLimit + ? Number.parseFloat(currentStats.currentUsageLimit) + : 0 + + if (!subscription || subscription.status !== 'active') { + // Downgraded to free + await db + .update(userStats) + .set({ + currentUsageLimit: getFreeTierLimit().toString(), + usageLimitUpdatedAt: new Date(), + }) + .where(eq(userStats.userId, userId)) + + logger.info('Set limit to free tier', { userId }) + } else if (currentLimit < defaultLimit) { + await db + .update(userStats) + .set({ currentUsageLimit: defaultLimit.toString(), usageLimitUpdatedAt: new Date(), }) - logger.info('Created usage stats with synced limit', { userId, limit: defaultLimit }) - return - } + .where(eq(userStats.userId, userId)) - const currentStats = currentUserStats[0] - const currentLimit = Number.parseFloat(currentStats.currentUsageLimit) - - // Only update if subscription is free plan or if current limit is below new minimum - if (!subscription || subscription.status !== 'active') { - // User downgraded to free plan - cap at default free credits - await db - .update(userStats) - .set({ - currentUsageLimit: DEFAULT_FREE_CREDITS.toString(), - usageLimitUpdatedAt: new Date(), - }) - .where(eq(userStats.userId, userId)) - - logger.info('Synced usage limit to free plan', { userId, limit: DEFAULT_FREE_CREDITS }) - } else if (currentLimit < defaultLimit) { - // User upgraded and current limit is below new minimum - raise to minimum - await db - .update(userStats) - .set({ - currentUsageLimit: defaultLimit.toString(), - usageLimitUpdatedAt: new Date(), - }) - .where(eq(userStats.userId, userId)) - - logger.info('Synced usage limit to new minimum', { - userId, - oldLimit: currentLimit, - newLimit: defaultLimit, - }) - } - // If user has higher custom limit, keep it unchanged - } catch (error) { - logger.error('Failed to sync usage limits', { userId, error }) - throw error + logger.info('Raised limit to plan minimum', { + userId, + newLimit: defaultLimit, + }) } + // Keep higher custom limits unchanged } /** @@ -453,8 +453,6 @@ export async function getTeamUsageLimits(organizationId: string): Promise< currentUsage: number totalCost: number lastActive: Date | null - limitSetBy: string | null - limitUpdatedAt: Date | null }> > { try { @@ -467,8 +465,6 @@ export async function getTeamUsageLimits(organizationId: string): Promise< currentPeriodCost: userStats.currentPeriodCost, totalCost: userStats.totalCost, lastActive: userStats.lastActive, - limitSetBy: userStats.usageLimitSetBy, - limitUpdatedAt: userStats.usageLimitUpdatedAt, }) .from(member) .innerJoin(user, eq(member.userId, user.id)) @@ -479,12 +475,10 @@ export async function getTeamUsageLimits(organizationId: string): Promise< userId: memberData.userId, userName: memberData.userName, userEmail: memberData.userEmail, - currentLimit: Number.parseFloat(memberData.currentLimit || DEFAULT_FREE_CREDITS.toString()), + currentLimit: Number.parseFloat(memberData.currentLimit || getFreeTierLimit().toString()), currentUsage: Number.parseFloat(memberData.currentPeriodCost || '0'), totalCost: Number.parseFloat(memberData.totalCost || '0'), lastActive: memberData.lastActive, - limitSetBy: memberData.limitSetBy, - limitUpdatedAt: memberData.limitUpdatedAt, })) } catch (error) { logger.error('Failed to get team usage limits', { organizationId, error }) diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts index d2caa6935..a239fd1b8 100644 --- a/apps/sim/lib/billing/index.ts +++ b/apps/sim/lib/billing/index.ts @@ -5,7 +5,6 @@ export * from '@/lib/billing/calculations/usage-monitor' export * from '@/lib/billing/core/billing' -export * from '@/lib/billing/core/billing-periods' export * from '@/lib/billing/core/organization-billing' export * from '@/lib/billing/core/subscription' export { @@ -25,9 +24,9 @@ export { } from '@/lib/billing/core/usage' export * from '@/lib/billing/subscriptions/utils' export { - calculateDefaultUsageLimit as getDefaultLimit, canEditUsageLimit as canEditLimit, getMinimumUsageLimit as getMinimumLimit, + getSubscriptionAllowance as getDefaultLimit, } from '@/lib/billing/subscriptions/utils' export * from '@/lib/billing/types' export * from '@/lib/billing/validation/seat-management' diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts new file mode 100644 index 000000000..da96b6bb3 --- /dev/null +++ b/apps/sim/lib/billing/organization.ts @@ -0,0 +1,184 @@ +import { and, eq } from 'drizzle-orm' +import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' +import { createLogger } from '@/lib/logs/console/logger' +import { db } from '@/db' +import * as schema from '@/db/schema' + +const logger = createLogger('BillingOrganization') + +type SubscriptionData = { + id: string + plan: string + referenceId: string + status: string + seats?: number +} + +/** + * Check if a user already owns an organization + */ +async function getUserOwnedOrganization(userId: string): Promise { + const existingMemberships = await db + .select({ organizationId: schema.member.organizationId }) + .from(schema.member) + .where(and(eq(schema.member.userId, userId), eq(schema.member.role, 'owner'))) + .limit(1) + + if (existingMemberships.length > 0) { + const [existingOrg] = await db + .select({ id: schema.organization.id }) + .from(schema.organization) + .where(eq(schema.organization.id, existingMemberships[0].organizationId)) + .limit(1) + + return existingOrg?.id || null + } + + return null +} + +/** + * Create a new organization and add user as owner + */ +async function createOrganizationWithOwner( + userId: string, + organizationName: string, + organizationSlug: string, + metadata: Record = {} +): Promise { + const orgId = `org_${crypto.randomUUID()}` + + const [newOrg] = await db + .insert(schema.organization) + .values({ + id: orgId, + name: organizationName, + slug: organizationSlug, + metadata, + }) + .returning({ id: schema.organization.id }) + + // Add user as owner/admin of the organization + await db.insert(schema.member).values({ + id: crypto.randomUUID(), + userId: userId, + organizationId: newOrg.id, + role: 'owner', + }) + + logger.info('Created organization with owner', { + userId, + organizationId: newOrg.id, + organizationName, + }) + + return newOrg.id +} + +/** + * Create organization for team/enterprise plan upgrade + */ +export async function createOrganizationForTeamPlan( + userId: string, + userName?: string, + userEmail?: string, + organizationSlug?: string +): Promise { + try { + // Check if user already owns an organization + const existingOrgId = await getUserOwnedOrganization(userId) + if (existingOrgId) { + return existingOrgId + } + + // Create new organization (same naming for both team and enterprise) + const organizationName = userName || `${userEmail || 'User'}'s Team` + const slug = organizationSlug || `${userId}-team-${Date.now()}` + + const orgId = await createOrganizationWithOwner(userId, organizationName, slug, { + createdForTeamPlan: true, + originalUserId: userId, + }) + + logger.info('Created organization for team/enterprise plan', { + userId, + organizationId: orgId, + organizationName, + }) + + return orgId + } catch (error) { + logger.error('Failed to create organization for team/enterprise plan', { + userId, + error, + }) + throw error + } +} + +/** + * Sync usage limits for subscription members + * Updates usage limits for all users associated with the subscription + */ +export async function syncSubscriptionUsageLimits(subscription: SubscriptionData) { + try { + logger.info('Syncing subscription usage limits', { + subscriptionId: subscription.id, + referenceId: subscription.referenceId, + plan: subscription.plan, + }) + + // Check if this is a user or organization subscription + const users = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.id, subscription.referenceId)) + .limit(1) + + if (users.length > 0) { + // Individual user subscription - sync their usage limits + await syncUsageLimitsFromSubscription(subscription.referenceId) + + logger.info('Synced usage limits for individual user subscription', { + userId: subscription.referenceId, + subscriptionId: subscription.id, + plan: subscription.plan, + }) + } else { + // Organization subscription - sync usage limits for all members + const members = await db + .select({ userId: schema.member.userId }) + .from(schema.member) + .where(eq(schema.member.organizationId, subscription.referenceId)) + + if (members.length > 0) { + for (const member of members) { + try { + await syncUsageLimitsFromSubscription(member.userId) + } catch (memberError) { + logger.error('Failed to sync usage limits for organization member', { + userId: member.userId, + organizationId: subscription.referenceId, + subscriptionId: subscription.id, + error: memberError, + }) + } + } + + logger.info('Synced usage limits for organization members', { + organizationId: subscription.referenceId, + memberCount: members.length, + subscriptionId: subscription.id, + plan: subscription.plan, + }) + } + } + } catch (error) { + logger.error('Failed to sync subscription usage limits', { + subscriptionId: subscription.id, + referenceId: subscription.referenceId, + error, + }) + throw error + } +} diff --git a/apps/sim/lib/billing/plans.ts b/apps/sim/lib/billing/plans.ts new file mode 100644 index 000000000..ca02eccb3 --- /dev/null +++ b/apps/sim/lib/billing/plans.ts @@ -0,0 +1,65 @@ +import { + getFreeTierLimit, + getProTierLimit, + getTeamTierLimitPerSeat, +} from '@/lib/billing/subscriptions/utils' +import { env } from '@/lib/env' + +export interface BillingPlan { + name: string + priceId: string + limits: { + cost: number + } +} + +/** + * Get the billing plans configuration for Better Auth Stripe plugin + */ +export function getPlans(): BillingPlan[] { + return [ + { + name: 'free', + priceId: env.STRIPE_FREE_PRICE_ID || '', + limits: { + cost: getFreeTierLimit(), + }, + }, + { + name: 'pro', + priceId: env.STRIPE_PRO_PRICE_ID || '', + limits: { + cost: getProTierLimit(), + }, + }, + { + name: 'team', + priceId: env.STRIPE_TEAM_PRICE_ID || '', + limits: { + cost: getTeamTierLimitPerSeat(), + }, + }, + { + name: 'enterprise', + priceId: 'price_dynamic', + limits: { + cost: getTeamTierLimitPerSeat(), + }, + }, + ] +} + +/** + * Get a specific plan by name + */ +export function getPlanByName(planName: string): BillingPlan | undefined { + return getPlans().find((plan) => plan.name === planName) +} + +/** + * Get plan limits for a given plan name + */ +export function getPlanLimits(planName: string): number { + const plan = getPlanByName(planName) + return plan?.limits.cost ?? getFreeTierLimit() +} diff --git a/apps/sim/lib/billing/stripe-client.ts b/apps/sim/lib/billing/stripe-client.ts index 047b9a6d0..93bcc6485 100644 --- a/apps/sim/lib/billing/stripe-client.ts +++ b/apps/sim/lib/billing/stripe-client.ts @@ -8,11 +8,7 @@ const logger = createLogger('StripeClient') * Check if Stripe credentials are valid */ export function hasValidStripeCredentials(): boolean { - return !!( - env.STRIPE_SECRET_KEY && - env.STRIPE_SECRET_KEY.trim() !== '' && - env.STRIPE_SECRET_KEY !== 'placeholder' - ) + return !!env.STRIPE_SECRET_KEY } /** diff --git a/apps/sim/lib/billing/subscriptions/utils.test.ts b/apps/sim/lib/billing/subscriptions/utils.test.ts index 1a13647e9..db45010ce 100644 --- a/apps/sim/lib/billing/subscriptions/utils.test.ts +++ b/apps/sim/lib/billing/subscriptions/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { calculateDefaultUsageLimit, checkEnterprisePlan } from '@/lib/billing/subscriptions/utils' +import { checkEnterprisePlan, getSubscriptionAllowance } from '@/lib/billing/subscriptions/utils' vi.mock('@/lib/env', () => ({ env: { @@ -28,50 +28,50 @@ describe('Subscription Utilities', () => { }) }) - describe('calculateDefaultUsageLimit', () => { + describe('getSubscriptionAllowance', () => { it.concurrent('returns free-tier limit when subscription is null', () => { - expect(calculateDefaultUsageLimit(null)).toBe(10) + expect(getSubscriptionAllowance(null)).toBe(10) }) it.concurrent('returns free-tier limit when subscription is undefined', () => { - expect(calculateDefaultUsageLimit(undefined)).toBe(10) + expect(getSubscriptionAllowance(undefined)).toBe(10) }) it.concurrent('returns free-tier limit when subscription is not active', () => { - expect(calculateDefaultUsageLimit({ plan: 'pro', status: 'canceled', seats: 1 })).toBe(10) + expect(getSubscriptionAllowance({ plan: 'pro', status: 'canceled', seats: 1 })).toBe(10) }) it.concurrent('returns pro limit for active pro plan', () => { - expect(calculateDefaultUsageLimit({ plan: 'pro', status: 'active', seats: 1 })).toBe(20) + expect(getSubscriptionAllowance({ plan: 'pro', status: 'active', seats: 1 })).toBe(20) }) it.concurrent('returns team limit multiplied by seats', () => { - expect(calculateDefaultUsageLimit({ plan: 'team', status: 'active', seats: 3 })).toBe(3 * 40) + expect(getSubscriptionAllowance({ plan: 'team', status: 'active', seats: 3 })).toBe(3 * 40) }) - it.concurrent('returns enterprise limit using perSeatAllowance metadata', () => { + it.concurrent('returns enterprise limit using perSeatPrice metadata', () => { const sub = { plan: 'enterprise', status: 'active', seats: 10, - metadata: { perSeatAllowance: '150' }, + metadata: { perSeatPrice: 150 }, } - expect(calculateDefaultUsageLimit(sub)).toBe(10 * 150) + expect(getSubscriptionAllowance(sub)).toBe(10 * 150) }) - it.concurrent('returns enterprise limit using totalAllowance metadata', () => { + it.concurrent('returns enterprise limit using perSeatPrice as string', () => { const sub = { plan: 'enterprise', status: 'active', seats: 8, - metadata: { totalAllowance: '5000' }, + metadata: { perSeatPrice: '250' }, } - expect(calculateDefaultUsageLimit(sub)).toBe(5000) + expect(getSubscriptionAllowance(sub)).toBe(8 * 250) }) it.concurrent('falls back to default enterprise tier when metadata missing', () => { const sub = { plan: 'enterprise', status: 'active', seats: 2, metadata: {} } - expect(calculateDefaultUsageLimit(sub)).toBe(2 * 200) + expect(getSubscriptionAllowance(sub)).toBe(2 * 200) }) }) }) diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts index f33f68d25..a2cc6c8cd 100644 --- a/apps/sim/lib/billing/subscriptions/utils.ts +++ b/apps/sim/lib/billing/subscriptions/utils.ts @@ -1,6 +1,40 @@ -import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants' +import { + DEFAULT_ENTERPRISE_TIER_COST_LIMIT, + DEFAULT_FREE_CREDITS, + DEFAULT_PRO_TIER_COST_LIMIT, + DEFAULT_TEAM_TIER_COST_LIMIT, +} from '@/lib/billing/constants' +import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types' import { env } from '@/lib/env' +/** + * Get the free tier limit from env or fallback to default + */ +export function getFreeTierLimit(): number { + return env.FREE_TIER_COST_LIMIT || DEFAULT_FREE_CREDITS +} + +/** + * Get the pro tier limit from env or fallback to default + */ +export function getProTierLimit(): number { + return env.PRO_TIER_COST_LIMIT || DEFAULT_PRO_TIER_COST_LIMIT +} + +/** + * Get the team tier limit per seat from env or fallback to default + */ +export function getTeamTierLimitPerSeat(): number { + return env.TEAM_TIER_COST_LIMIT || DEFAULT_TEAM_TIER_COST_LIMIT +} + +/** + * Get the enterprise tier limit per seat from env or fallback to default + */ +export function getEnterpriseTierLimitPerSeat(): number { + return env.ENTERPRISE_TIER_COST_LIMIT || DEFAULT_ENTERPRISE_TIER_COST_LIMIT +} + export function checkEnterprisePlan(subscription: any): boolean { return subscription?.plan === 'enterprise' && subscription?.status === 'active' } @@ -14,39 +48,82 @@ export function checkTeamPlan(subscription: any): boolean { } /** - * Calculate default usage limit for a subscription based on its type and metadata - * This is now used as the minimum limit for paid plans + * Calculate the total subscription-level allowance (what the org/user gets for their base payment) + * - Pro: Fixed amount per user + * - Team: Seats * base price (pooled for the org) + * - Enterprise: Seats * per-seat price (pooled, with optional custom pricing in metadata) * @param subscription The subscription object - * @returns The calculated default usage limit in dollars + * @returns The total subscription allowance in dollars */ -export function calculateDefaultUsageLimit(subscription: any): number { +export function getSubscriptionAllowance(subscription: any): number { if (!subscription || subscription.status !== 'active') { - return env.FREE_TIER_COST_LIMIT || DEFAULT_FREE_CREDITS + return getFreeTierLimit() } const seats = subscription.seats || 1 if (subscription.plan === 'pro') { - return env.PRO_TIER_COST_LIMIT || 0 + return getProTierLimit() } if (subscription.plan === 'team') { - return seats * (env.TEAM_TIER_COST_LIMIT || 0) + return seats * getTeamTierLimitPerSeat() } if (subscription.plan === 'enterprise') { - const metadata = subscription.metadata || {} + const metadata = subscription.metadata as EnterpriseSubscriptionMetadata | undefined - if (metadata.perSeatAllowance) { - return seats * Number.parseFloat(metadata.perSeatAllowance) + // Enterprise uses per-seat pricing (pooled like Team) + // Custom per-seat price can be set in metadata + let perSeatPrice = getEnterpriseTierLimitPerSeat() + if (metadata?.perSeatPrice) { + const parsed = Number.parseFloat(String(metadata.perSeatPrice)) + if (parsed > 0 && !Number.isNaN(parsed)) { + perSeatPrice = parsed + } } - if (metadata.totalAllowance) { - return Number.parseFloat(metadata.totalAllowance) - } - - return seats * (env.ENTERPRISE_TIER_COST_LIMIT || 0) + return seats * perSeatPrice } - return env.FREE_TIER_COST_LIMIT || DEFAULT_FREE_CREDITS + return getFreeTierLimit() +} + +/** + * Get the minimum usage limit for an individual user (used for validation) + * - Pro: User's plan minimum + * - Team: 0 (pooled model, no individual minimums) + * - Enterprise: 0 (pooled model, no individual minimums) + * @param subscription The subscription object + * @returns The per-user minimum limit in dollars + */ +export function getPerUserMinimumLimit(subscription: any): number { + if (!subscription || subscription.status !== 'active') { + return getFreeTierLimit() + } + + const seats = subscription.seats || 1 + + if (subscription.plan === 'pro') { + return getProTierLimit() + } + if (subscription.plan === 'team') { + // For team plans, return the total pooled limit (seats * cost per seat) + // This becomes the user's individual limit representing their share of the team pool + return seats * getTeamTierLimitPerSeat() + } + if (subscription.plan === 'enterprise') { + // For enterprise plans, return the total pooled limit (seats * cost per seat) + // This becomes the user's individual limit representing their share of the enterprise pool + let perSeatPrice = getEnterpriseTierLimitPerSeat() + if (subscription.metadata?.perSeatPrice) { + const parsed = Number.parseFloat(String(subscription.metadata.perSeatPrice)) + if (parsed > 0 && !Number.isNaN(parsed)) { + perSeatPrice = parsed + } + } + return seats * perSeatPrice + } + + return getFreeTierLimit() } /** @@ -74,5 +151,5 @@ export function canEditUsageLimit(subscription: any): boolean { * @returns The minimum allowed usage limit in dollars */ export function getMinimumUsageLimit(subscription: any): number { - return calculateDefaultUsageLimit(subscription) + return getPerUserMinimumLimit(subscription) } diff --git a/apps/sim/lib/billing/team-management.ts b/apps/sim/lib/billing/team-management.ts deleted file mode 100644 index 40e70b3b1..000000000 --- a/apps/sim/lib/billing/team-management.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { eq } from 'drizzle-orm' -import { createLogger } from '@/lib/logs/console/logger' -import { db } from '@/db' -import { member, organization, session, subscription, user } from '@/db/schema' - -const logger = createLogger('TeamManagement') - -type SubscriptionData = { - id: string - plan: string - referenceId: string - status: string - seats?: number - [key: string]: any -} - -/** - * Auto-create organization for team plan subscriptions - */ -export async function handleTeamPlanOrganization( - subscriptionData: SubscriptionData -): Promise { - if (subscriptionData.plan !== 'team') return - - try { - // For team subscriptions, referenceId should be the user ID initially - // But if the organization has already been created, it might be the org ID - let userId: string = subscriptionData.referenceId - let currentUser: any = null - - // First try to get user directly (most common case) - const users = await db - .select() - .from(user) - .where(eq(user.id, subscriptionData.referenceId)) - .limit(1) - - if (users.length > 0) { - currentUser = users[0] - userId = currentUser.id - } else { - // If referenceId is not a user ID, it might be an organization ID - // In that case, the organization already exists, so we should skip - const existingOrg = await db - .select() - .from(organization) - .where(eq(organization.id, subscriptionData.referenceId)) - .limit(1) - - if (existingOrg.length > 0) { - logger.info('Organization already exists for team subscription, skipping creation', { - organizationId: subscriptionData.referenceId, - subscriptionId: subscriptionData.id, - }) - return - } - - logger.warn('User not found for team subscription and no existing organization', { - referenceId: subscriptionData.referenceId, - }) - return - } - - // Check if user already has an organization membership - const existingMember = await db.select().from(member).where(eq(member.userId, userId)).limit(1) - - if (existingMember.length > 0) { - logger.info('User already has organization membership, skipping auto-creation', { - userId, - existingOrgId: existingMember[0].organizationId, - }) - return - } - - const orgName = `${currentUser.name || 'User'}'s Team` - const orgSlug = `${currentUser.name?.toLowerCase().replace(/\s+/g, '-') || 'team'}-${Date.now()}` - - // Create organization directly in database - const orgId = `org_${Date.now()}_${Math.random().toString(36).substring(2, 15)}` - - const [createdOrg] = await db - .insert(organization) - .values({ - id: orgId, - name: orgName, - slug: orgSlug, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - if (!createdOrg) { - throw new Error('Failed to create organization in database') - } - - // Add the user as admin of the organization (owner role for full control) - await db.insert(member).values({ - id: `member_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`, - userId: currentUser.id, - organizationId: orgId, - role: 'owner', // Owner gives full admin privileges - createdAt: new Date(), - }) - - // Update the subscription to reference the organization instead of the user - await db - .update(subscription) - .set({ referenceId: orgId }) - .where(eq(subscription.id, subscriptionData.id)) - - // Update the user's session to set the new organization as active - await db - .update(session) - .set({ activeOrganizationId: orgId }) - .where(eq(session.userId, currentUser.id)) - - logger.info('Auto-created organization for team subscription', { - organizationId: orgId, - userId: currentUser.id, - subscriptionId: subscriptionData.id, - orgName, - userRole: 'owner', - }) - - // Update subscription object for subsequent logic - subscriptionData.referenceId = orgId - } catch (error) { - logger.error('Failed to auto-create organization for team subscription', { - subscriptionId: subscriptionData.id, - referenceId: subscriptionData.referenceId, - error, - }) - throw error - } -} - -/** - * Sync usage limits for user or organization - * Handles the complexity of determining whether to sync for user ID or org members - */ -export async function syncSubscriptionUsageLimits( - subscriptionData: SubscriptionData -): Promise { - try { - const { syncUsageLimitsFromSubscription } = await import('@/lib/billing') - - // For team plans, the referenceId is now an organization ID - // We need to sync limits for the organization members - if (subscriptionData.plan === 'team') { - // Get all members of the organization - const orgMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, subscriptionData.referenceId)) - - // Sync usage limits for each member - for (const orgMember of orgMembers) { - await syncUsageLimitsFromSubscription(orgMember.userId) - } - - logger.info('Synced usage limits for team organization members', { - organizationId: subscriptionData.referenceId, - memberCount: orgMembers.length, - }) - } else { - // For non-team plans, referenceId is the user ID - await syncUsageLimitsFromSubscription(subscriptionData.referenceId) - logger.info('Synced usage limits for user', { - userId: subscriptionData.referenceId, - plan: subscriptionData.plan, - }) - } - } catch (error) { - logger.error('Failed to sync subscription usage limits', { - subscriptionId: subscriptionData.id, - referenceId: subscriptionData.referenceId, - error, - }) - throw error - } -} diff --git a/apps/sim/lib/billing/types/index.ts b/apps/sim/lib/billing/types/index.ts index b0e47497c..4f43cef2e 100644 --- a/apps/sim/lib/billing/types/index.ts +++ b/apps/sim/lib/billing/types/index.ts @@ -3,10 +3,17 @@ * Centralized type definitions for the billing system */ -export interface SubscriptionFeatures { - sharingEnabled: boolean - multiplayerEnabled: boolean - workspaceCollaborationEnabled: boolean +export interface EnterpriseSubscriptionMetadata { + plan: 'enterprise' + // Custom per-seat pricing (defaults to DEFAULT_ENTERPRISE_TIER_COST_LIMIT) + referenceId: string + perSeatPrice?: number + + // Maximum allowed seats (defaults to subscription.seats) + maxSeats?: number + + // Whether seats are fixed and cannot be changed + fixedSeats?: boolean } export interface UsageData { @@ -25,7 +32,6 @@ export interface UsageLimitInfo { canEdit: boolean minimumLimit: number plan: string - setBy: string | null updatedAt: Date | null } @@ -44,7 +50,6 @@ export interface UserSubscriptionState { isEnterprise: boolean isFree: boolean highestPrioritySubscription: any | null - features: SubscriptionFeatures hasExceededLimit: boolean planName: string } @@ -54,9 +59,6 @@ export interface SubscriptionPlan { priceId: string limits: { cost: number - sharingEnabled: number - multiplayerEnabled: number - workspaceCollaborationEnabled: number } } @@ -137,7 +139,6 @@ export interface SubscriptionAPIResponse { status: string | null seats: number | null metadata: any | null - features: SubscriptionFeatures usage: UsageData } @@ -190,12 +191,10 @@ export interface UseSubscriptionStateReturn { seats?: number metadata?: any } - features: SubscriptionFeatures usage: UsageData isLoading: boolean error: Error | null refetch: () => Promise - hasFeature: (feature: keyof SubscriptionFeatures) => boolean isAtLeastPro: () => boolean isAtLeastTeam: () => boolean canUpgrade: () => boolean diff --git a/apps/sim/lib/billing/validation/seat-management.ts b/apps/sim/lib/billing/validation/seat-management.ts index 4ff9ac10c..5c567a77d 100644 --- a/apps/sim/lib/billing/validation/seat-management.ts +++ b/apps/sim/lib/billing/validation/seat-management.ts @@ -1,5 +1,6 @@ import { and, count, eq } from 'drizzle-orm' import { getOrganizationSubscription } from '@/lib/billing/core/billing' +import type { EnterpriseSubscriptionMetadata } from '@/lib/billing/types' import { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' @@ -71,11 +72,11 @@ export async function validateSeatAvailability( // For enterprise plans, check metadata for custom seat allowances if (subscription.plan === 'enterprise' && subscription.metadata) { try { - const metadata = + const metadata: EnterpriseSubscriptionMetadata = typeof subscription.metadata === 'string' ? JSON.parse(subscription.metadata) : subscription.metadata - if (metadata.maxSeats) { + if (metadata.maxSeats && typeof metadata.maxSeats === 'number') { maxSeats = metadata.maxSeats } } catch (error) { @@ -166,11 +167,11 @@ export async function getOrganizationSeatInfo( if (subscription.plan === 'enterprise' && subscription.metadata) { try { - const metadata = + const metadata: EnterpriseSubscriptionMetadata = typeof subscription.metadata === 'string' ? JSON.parse(subscription.metadata) : subscription.metadata - if (metadata.maxSeats) { + if (metadata.maxSeats && typeof metadata.maxSeats === 'number') { maxSeats = metadata.maxSeats } // Enterprise plans might have fixed seat counts diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts new file mode 100644 index 000000000..0b79a9bb9 --- /dev/null +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -0,0 +1,337 @@ +import { eq } from 'drizzle-orm' +import type Stripe from 'stripe' +import { getUserUsageData } from '@/lib/billing/core/usage' +import { requireStripeClient } from '@/lib/billing/stripe-client' +import { createLogger } from '@/lib/logs/console/logger' +import { db } from '@/db' +import { member, subscription as subscriptionTable, userStats } from '@/db/schema' + +const logger = createLogger('StripeInvoiceWebhooks') + +async function resetUsageForSubscription(sub: { plan: string | null; referenceId: string }) { + if (sub.plan === 'team' || sub.plan === 'enterprise') { + const membersRows = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) + + for (const m of membersRows) { + const currentStats = await db + .select({ current: userStats.currentPeriodCost }) + .from(userStats) + .where(eq(userStats.userId, m.userId)) + .limit(1) + if (currentStats.length > 0) { + const current = currentStats[0].current || '0' + await db + .update(userStats) + .set({ lastPeriodCost: current, currentPeriodCost: '0' }) + .where(eq(userStats.userId, m.userId)) + } + } + } else { + const currentStats = await db + .select({ current: userStats.currentPeriodCost }) + .from(userStats) + .where(eq(userStats.userId, sub.referenceId)) + .limit(1) + if (currentStats.length > 0) { + const current = currentStats[0].current || '0' + await db + .update(userStats) + .set({ lastPeriodCost: current, currentPeriodCost: '0' }) + .where(eq(userStats.userId, sub.referenceId)) + } + } +} + +/** + * Handle invoice payment succeeded webhook + * We unblock any previously blocked users for this subscription. + */ +export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { + try { + const invoice = event.data.object as Stripe.Invoice + + if (!invoice.subscription) return + const stripeSubscriptionId = String(invoice.subscription) + const records = await db + .select() + .from(subscriptionTable) + .where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId)) + .limit(1) + + if (records.length === 0) return + const sub = records[0] + + // Only reset usage here if the tenant was previously blocked; otherwise invoice.created already reset it + let wasBlocked = false + if (sub.plan === 'team' || sub.plan === 'enterprise') { + const membersRows = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) + for (const m of membersRows) { + const row = await db + .select({ blocked: userStats.billingBlocked }) + .from(userStats) + .where(eq(userStats.userId, m.userId)) + .limit(1) + if (row.length > 0 && row[0].blocked) { + wasBlocked = true + break + } + } + } else { + const row = await db + .select({ blocked: userStats.billingBlocked }) + .from(userStats) + .where(eq(userStats.userId, sub.referenceId)) + .limit(1) + wasBlocked = row.length > 0 ? !!row[0].blocked : false + } + + if (sub.plan === 'team' || sub.plan === 'enterprise') { + const members = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) + for (const m of members) { + await db + .update(userStats) + .set({ billingBlocked: false }) + .where(eq(userStats.userId, m.userId)) + } + } else { + await db + .update(userStats) + .set({ billingBlocked: false }) + .where(eq(userStats.userId, sub.referenceId)) + } + + if (wasBlocked) { + await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) + } + } catch (error) { + logger.error('Failed to handle invoice payment succeeded', { eventId: event.id, error }) + throw error + } +} + +/** + * Handle invoice payment failed webhook + * This is triggered when a user's payment fails for a usage billing invoice + */ +export async function handleInvoicePaymentFailed(event: Stripe.Event) { + try { + const invoice = event.data.object as Stripe.Invoice + + // Check if this is an overage billing invoice + if (invoice.metadata?.type !== 'overage_billing') { + logger.info('Ignoring non-overage billing invoice payment failure', { invoiceId: invoice.id }) + return + } + + const customerId = invoice.customer as string + const failedAmount = invoice.amount_due / 100 // Convert from cents to dollars + const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' + const attemptCount = invoice.attempt_count || 1 + + logger.warn('Overage billing invoice payment failed', { + invoiceId: invoice.id, + customerId, + failedAmount, + billingPeriod, + attemptCount, + customerEmail: invoice.customer_email, + hostedInvoiceUrl: invoice.hosted_invoice_url, + }) + + // Implement dunning management logic here + // For example: suspend service after multiple failures, notify admins, etc. + if (attemptCount >= 1) { + logger.error('Multiple payment failures for overage billing', { + invoiceId: invoice.id, + customerId, + attemptCount, + }) + // Block all users under this customer (org members or individual) + const stripeSubscriptionId = String(invoice.subscription || '') + if (stripeSubscriptionId) { + const records = await db + .select() + .from(subscriptionTable) + .where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId)) + .limit(1) + + if (records.length > 0) { + const sub = records[0] + if (sub.plan === 'team' || sub.plan === 'enterprise') { + const members = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) + for (const m of members) { + await db + .update(userStats) + .set({ billingBlocked: true }) + .where(eq(userStats.userId, m.userId)) + } + } else { + await db + .update(userStats) + .set({ billingBlocked: true }) + .where(eq(userStats.userId, sub.referenceId)) + } + } + } + } + } catch (error) { + logger.error('Failed to handle invoice payment failed', { + eventId: event.id, + error, + }) + throw error // Re-throw to signal webhook failure + } +} + +/** + * Handle base invoice finalized → create a separate overage-only invoice + */ +export async function handleInvoiceFinalized(event: Stripe.Event) { + try { + const invoice = event.data.object as Stripe.Invoice + // Only run for subscription renewal invoices (cycle boundary) + if (!invoice.subscription) return + if (invoice.billing_reason && invoice.billing_reason !== 'subscription_cycle') return + + const stripeSubscriptionId = String(invoice.subscription) + const records = await db + .select() + .from(subscriptionTable) + .where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId)) + .limit(1) + + if (records.length === 0) return + const sub = records[0] + + const stripe = requireStripeClient() + const periodEnd = + invoice.lines?.data?.[0]?.period?.end || invoice.period_end || Math.floor(Date.now() / 1000) + const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7) + + // Compute overage + let totalOverage = 0 + if (sub.plan === 'team' || sub.plan === 'enterprise') { + const members = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) + + let totalTeamUsage = 0 + for (const m of members) { + const usage = await getUserUsageData(m.userId) + totalTeamUsage += usage.currentUsage + } + + const { getPlanPricing } = await import('@/lib/billing/core/billing') + const { basePrice } = getPlanPricing(sub.plan, sub) + const baseSubscriptionAmount = (sub.seats || 1) * basePrice + totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount) + } else { + const usage = await getUserUsageData(sub.referenceId) + const { getPlanPricing } = await import('@/lib/billing/core/billing') + const { basePrice } = getPlanPricing(sub.plan, sub) + totalOverage = Math.max(0, usage.currentUsage - basePrice) + } + + // Always reset usage at cycle end, regardless of whether overage > 0 + await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) + + if (totalOverage <= 0) return + + const customerId = String(invoice.customer) + const cents = Math.round(totalOverage * 100) + const itemIdemKey = `overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + const invoiceIdemKey = `overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}` + + // Inherit billing settings from the Stripe subscription/customer for autopay + const getPaymentMethodId = ( + pm: string | Stripe.PaymentMethod | null | undefined + ): string | undefined => (typeof pm === 'string' ? pm : pm?.id) + + let collectionMethod: 'charge_automatically' | 'send_invoice' = 'charge_automatically' + let defaultPaymentMethod: string | undefined + try { + const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId) + if (stripeSub.collection_method === 'send_invoice') { + collectionMethod = 'send_invoice' + } + const subDpm = getPaymentMethodId(stripeSub.default_payment_method) + if (subDpm) { + defaultPaymentMethod = subDpm + } else if (collectionMethod === 'charge_automatically') { + const custObj = await stripe.customers.retrieve(customerId) + if (custObj && !('deleted' in custObj)) { + const cust = custObj as Stripe.Customer + const custDpm = getPaymentMethodId(cust.invoice_settings?.default_payment_method) + if (custDpm) defaultPaymentMethod = custDpm + } + } + } catch (e) { + logger.error('Failed to retrieve subscription or customer', { error: e }) + } + + // Create a draft invoice first so we can attach the item directly + const overageInvoice = await stripe.invoices.create( + { + customer: customerId, + collection_method: collectionMethod, + auto_advance: false, + ...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}), + metadata: { + type: 'overage_billing', + billingPeriod, + subscriptionId: stripeSubscriptionId, + }, + }, + { idempotencyKey: invoiceIdemKey } + ) + + // Attach the item to this invoice + await stripe.invoiceItems.create( + { + customer: customerId, + invoice: overageInvoice.id, + amount: cents, + currency: 'usd', + description: `Usage Based Overage – ${billingPeriod}`, + metadata: { + type: 'overage_billing', + billingPeriod, + subscriptionId: stripeSubscriptionId, + }, + }, + { idempotencyKey: itemIdemKey } + ) + + // Finalize to trigger autopay (if charge_automatically and a PM is present) + const finalized = await stripe.invoices.finalizeInvoice(overageInvoice.id) + // Some manual invoices may remain open after finalize; ensure we pay immediately when possible + if (collectionMethod === 'charge_automatically' && finalized.status === 'open') { + try { + await stripe.invoices.pay(finalized.id, { + payment_method: defaultPaymentMethod, + }) + } catch (payError) { + logger.error('Failed to auto-pay overage invoice', { + error: payError, + invoiceId: finalized.id, + }) + } + } + } catch (error) { + logger.error('Failed to handle invoice finalized', { error }) + throw error + } +} diff --git a/apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts b/apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts deleted file mode 100644 index 7cf1b46c4..000000000 --- a/apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { eq } from 'drizzle-orm' -import type Stripe from 'stripe' -import { - resetOrganizationBillingPeriod, - resetUserBillingPeriod, -} from '@/lib/billing/core/billing-periods' -import { createLogger } from '@/lib/logs/console/logger' -import { db } from '@/db' -import { subscription as subscriptionTable } from '@/db/schema' - -const logger = createLogger('StripeInvoiceWebhooks') - -/** - * Handle invoice payment succeeded webhook - * This is triggered when a user successfully pays a usage billing invoice - */ -export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { - try { - const invoice = event.data.object as Stripe.Invoice - - // Case 1: Overage invoices (metadata.type === 'overage_billing') - if (invoice.metadata?.type === 'overage_billing') { - const customerId = invoice.customer as string - const chargedAmount = invoice.amount_paid / 100 - const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' - - logger.info('Overage billing invoice payment succeeded', { - invoiceId: invoice.id, - customerId, - chargedAmount, - billingPeriod, - customerEmail: invoice.customer_email, - hostedInvoiceUrl: invoice.hosted_invoice_url, - }) - - return - } - - // Case 2: Subscription renewal invoice paid (primary period rollover) - // Only reset on successful payment to avoid granting a new period while in dunning - if (invoice.subscription) { - // Filter to subscription-cycle renewals; ignore updates/off-cycle charges - const reason = invoice.billing_reason - const isCycle = reason === 'subscription_cycle' - if (!isCycle) { - logger.info('Ignoring non-cycle subscription invoice on payment_succeeded', { - invoiceId: invoice.id, - billingReason: reason, - }) - return - } - - const stripeSubscriptionId = String(invoice.subscription) - const records = await db - .select() - .from(subscriptionTable) - .where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId)) - .limit(1) - - if (records.length === 0) { - logger.warn('No matching internal subscription for paid Stripe invoice', { - invoiceId: invoice.id, - stripeSubscriptionId, - }) - return - } - - const sub = records[0] - - if (sub.plan === 'team' || sub.plan === 'enterprise') { - await resetOrganizationBillingPeriod(sub.referenceId) - logger.info('Reset organization billing period on subscription invoice payment', { - invoiceId: invoice.id, - organizationId: sub.referenceId, - plan: sub.plan, - }) - } else { - await resetUserBillingPeriod(sub.referenceId) - logger.info('Reset user billing period on subscription invoice payment', { - invoiceId: invoice.id, - userId: sub.referenceId, - plan: sub.plan, - }) - } - - return - } - - logger.info('Ignoring non-subscription invoice payment', { invoiceId: invoice.id }) - } catch (error) { - logger.error('Failed to handle invoice payment succeeded', { - eventId: event.id, - error, - }) - throw error // Re-throw to signal webhook failure - } -} - -/** - * Handle invoice payment failed webhook - * This is triggered when a user's payment fails for a usage billing invoice - */ -export async function handleInvoicePaymentFailed(event: Stripe.Event) { - try { - const invoice = event.data.object as Stripe.Invoice - - // Check if this is an overage billing invoice - if (invoice.metadata?.type !== 'overage_billing') { - logger.info('Ignoring non-overage billing invoice payment failure', { invoiceId: invoice.id }) - return - } - - const customerId = invoice.customer as string - const failedAmount = invoice.amount_due / 100 // Convert from cents to dollars - const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' - const attemptCount = invoice.attempt_count || 1 - - logger.warn('Overage billing invoice payment failed', { - invoiceId: invoice.id, - customerId, - failedAmount, - billingPeriod, - attemptCount, - customerEmail: invoice.customer_email, - hostedInvoiceUrl: invoice.hosted_invoice_url, - }) - - // Implement dunning management logic here - // For example: suspend service after multiple failures, notify admins, etc. - if (attemptCount >= 3) { - logger.error('Multiple payment failures for overage billing', { - invoiceId: invoice.id, - customerId, - attemptCount, - }) - - // Could implement service suspension here - // await suspendUserService(customerId) - } - } catch (error) { - logger.error('Failed to handle invoice payment failed', { - eventId: event.id, - error, - }) - throw error // Re-throw to signal webhook failure - } -} - -/** - * Handle invoice finalized webhook - * This is triggered when a usage billing invoice is finalized and ready for payment - */ -export async function handleInvoiceFinalized(event: Stripe.Event) { - try { - const invoice = event.data.object as Stripe.Invoice - // Do not reset usage on finalized; wait for payment success to avoid granting new period during dunning - if (invoice.metadata?.type === 'overage_billing') { - const customerId = invoice.customer as string - const invoiceAmount = invoice.amount_due / 100 - const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' - logger.info('Overage billing invoice finalized', { - invoiceId: invoice.id, - customerId, - invoiceAmount, - billingPeriod, - }) - return - } - logger.info('Ignoring subscription invoice finalization; will act on payment_succeeded', { - invoiceId: invoice.id, - billingReason: invoice.billing_reason, - }) - } catch (error) { - logger.error('Failed to handle invoice finalized', { - eventId: event.id, - error, - }) - throw error // Re-throw to signal webhook failure - } -} - -/** - * Main webhook handler for all invoice-related events - */ -export async function handleInvoiceWebhook(event: Stripe.Event) { - switch (event.type) { - case 'invoice.payment_succeeded': - await handleInvoicePaymentSucceeded(event) - break - - case 'invoice.payment_failed': - await handleInvoicePaymentFailed(event) - break - - case 'invoice.finalized': - await handleInvoiceFinalized(event) - break - - default: - logger.info('Unhandled invoice webhook event', { eventType: event.type }) - } -} diff --git a/apps/sim/lib/env.ts b/apps/sim/lib/env.ts index 49dd0a361..67336a644 100644 --- a/apps/sim/lib/env.ts +++ b/apps/sim/lib/env.ts @@ -38,7 +38,6 @@ export const env = createEnv({ // Payment & Billing STRIPE_SECRET_KEY: z.string().min(1).optional(), // Stripe secret key for payment processing - STRIPE_BILLING_WEBHOOK_SECRET: z.string().min(1).optional(), // Webhook secret for billing events STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), // General Stripe webhook secret STRIPE_FREE_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for free tier FREE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for free tier users diff --git a/apps/sim/lib/logs/execution/logger.test.ts b/apps/sim/lib/logs/execution/logger.test.ts index 50ef98676..31138263c 100644 --- a/apps/sim/lib/logs/execution/logger.test.ts +++ b/apps/sim/lib/logs/execution/logger.test.ts @@ -14,18 +14,4 @@ describe('ExecutionLogger', () => { expect(logger).toBeInstanceOf(ExecutionLogger) }) }) - - describe('getTriggerPrefix', () => { - test('should return correct prefixes for trigger types', () => { - // Access the private method for testing - const getTriggerPrefix = (logger as any).getTriggerPrefix.bind(logger) - - expect(getTriggerPrefix('api')).toBe('API') - expect(getTriggerPrefix('webhook')).toBe('Webhook') - expect(getTriggerPrefix('schedule')).toBe('Scheduled') - expect(getTriggerPrefix('manual')).toBe('Manual') - expect(getTriggerPrefix('chat')).toBe('Chat') - expect(getTriggerPrefix('unknown' as any)).toBe('Unknown') - }) - }) }) diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 943405ec1..b13a0fc8e 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -272,32 +272,8 @@ export class ExecutionLogger implements IExecutionLoggerService { // Check if user stats record exists const userStatsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId)) - if (userStatsRecords.length === 0) { - // Create new user stats record with trigger-specific counts - const triggerCounts = this.getTriggerCounts(trigger) - - await db.insert(userStats).values({ - id: crypto.randomUUID(), - userId: userId, - totalManualExecutions: triggerCounts.manual, - totalApiCalls: triggerCounts.api, - totalWebhookTriggers: triggerCounts.webhook, - totalScheduledExecutions: triggerCounts.schedule, - totalChatExecutions: triggerCounts.chat, - totalTokensUsed: costSummary.totalTokens, - totalCost: costToStore.toString(), - currentPeriodCost: costToStore.toString(), // Initialize current period usage - lastActive: new Date(), - }) - - logger.debug('Created new user stats record with cost data', { - userId, - trigger, - totalCost: costToStore, - totalTokens: costSummary.totalTokens, - }) - } else { - // Update existing user stats record with trigger-specific increments + if (userStatsRecords.length > 0) { + // Update user stats record with trigger-specific increments const updateFields: any = { totalTokensUsed: sql`total_tokens_used + ${costSummary.totalTokens}`, totalCost: sql`total_cost + ${costToStore}`, @@ -326,12 +302,18 @@ export class ExecutionLogger implements IExecutionLoggerService { await db.update(userStats).set(updateFields).where(eq(userStats.userId, userId)) - logger.debug('Updated existing user stats record with cost data', { + logger.debug('Updated user stats record with cost data', { userId, trigger, addedCost: costToStore, addedTokens: costSummary.totalTokens, }) + } else { + logger.error('User stats record not found - should be created during onboarding', { + userId, + trigger, + }) + return // Skip cost tracking if user stats doesn't exist } } catch (error) { logger.error('Error updating user stats with cost information', { @@ -343,54 +325,6 @@ export class ExecutionLogger implements IExecutionLoggerService { } } - /** - * Get trigger counts for new user stats records - */ - private getTriggerCounts(trigger: ExecutionTrigger['type']): { - manual: number - api: number - webhook: number - schedule: number - chat: number - } { - const counts = { manual: 0, api: 0, webhook: 0, schedule: 0, chat: 0 } - switch (trigger) { - case 'manual': - counts.manual = 1 - break - case 'api': - counts.api = 1 - break - case 'webhook': - counts.webhook = 1 - break - case 'schedule': - counts.schedule = 1 - break - case 'chat': - counts.chat = 1 - break - } - return counts - } - - private getTriggerPrefix(triggerType: ExecutionTrigger['type']): string { - switch (triggerType) { - case 'api': - return 'API' - case 'webhook': - return 'Webhook' - case 'schedule': - return 'Scheduled' - case 'manual': - return 'Manual' - case 'chat': - return 'Chat' - default: - return 'Unknown' - } - } - /** * Extract file references from execution trace spans and final output */ diff --git a/apps/sim/lib/subscription/upgrade.ts b/apps/sim/lib/subscription/upgrade.ts new file mode 100644 index 000000000..40cb00593 --- /dev/null +++ b/apps/sim/lib/subscription/upgrade.ts @@ -0,0 +1,175 @@ +import { useCallback } from 'react' +import { client, useSession, useSubscription } from '@/lib/auth-client' +import { createLogger } from '@/lib/logs/console/logger' +import { useOrganizationStore } from '@/stores/organization' + +const logger = createLogger('SubscriptionUpgrade') + +type TargetPlan = 'pro' | 'team' + +const CONSTANTS = { + INITIAL_TEAM_SEATS: 1, +} as const + +/** + * Handles organization creation for team plans and proper referenceId management + */ +export function useSubscriptionUpgrade() { + const { data: session } = useSession() + const betterAuthSubscription = useSubscription() + const { loadData: loadOrganizationData } = useOrganizationStore() + + const handleUpgrade = useCallback( + async (targetPlan: TargetPlan) => { + const userId = session?.user?.id + if (!userId) { + throw new Error('User not authenticated') + } + + let currentSubscriptionId: string | undefined + try { + const listResult = await client.subscription.list() + const activePersonalSub = listResult.data?.find( + (sub: any) => sub.status === 'active' && sub.referenceId === userId + ) + currentSubscriptionId = activePersonalSub?.id + } catch (_e) { + currentSubscriptionId = undefined + } + + let referenceId = userId + + // For team plans, create organization first and use its ID as referenceId + if (targetPlan === 'team') { + try { + logger.info('Creating organization for team plan upgrade', { + userId, + }) + + const response = await fetch('/api/organizations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to create organization: ${response.statusText}`) + } + + const result = await response.json() + + logger.info('Organization API response', { + result, + success: result.success, + organizationId: result.organizationId, + }) + + if (!result.success || !result.organizationId) { + throw new Error('Failed to create organization for team plan') + } + + referenceId = result.organizationId + + // Set the organization as active so Better Auth recognizes it + try { + await client.organization.setActive({ organizationId: result.organizationId }) + + logger.info('Set organization as active and updated referenceId', { + organizationId: result.organizationId, + oldReferenceId: userId, + newReferenceId: referenceId, + }) + } catch (error) { + logger.warn('Failed to set organization as active, but proceeding with upgrade', { + organizationId: result.organizationId, + error: error instanceof Error ? error.message : 'Unknown error', + }) + // Continue with upgrade even if setting active fails + } + + if (currentSubscriptionId) { + const transferResponse = await fetch( + `/api/users/me/subscription/${currentSubscriptionId}/transfer`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ organizationId: referenceId }), + } + ) + + if (!transferResponse.ok) { + const text = await transferResponse.text() + throw new Error(text || 'Failed to transfer subscription to organization') + } + } + } catch (error) { + logger.error('Failed to create organization for team plan', error) + throw new Error('Failed to create team workspace. Please try again or contact support.') + } + } + + const currentUrl = `${window.location.origin}${window.location.pathname}` + + try { + const upgradeParams = { + plan: targetPlan, + referenceId, + successUrl: currentUrl, + cancelUrl: currentUrl, + ...(targetPlan === 'team' && { seats: CONSTANTS.INITIAL_TEAM_SEATS }), + } as const + + // Add subscriptionId for existing subscriptions to ensure proper plan switching + const finalParams = currentSubscriptionId + ? { ...upgradeParams, subscriptionId: currentSubscriptionId } + : upgradeParams + + logger.info( + currentSubscriptionId ? 'Upgrading existing subscription' : 'Creating new subscription', + { + targetPlan, + currentSubscriptionId, + referenceId, + } + ) + + await betterAuthSubscription.upgrade(finalParams) + + // For team plans, refresh organization data to ensure UI updates + if (targetPlan === 'team') { + try { + await loadOrganizationData() + logger.info('Refreshed organization data after team upgrade') + } catch (error) { + logger.warn('Failed to refresh organization data after upgrade', error) + // Don't fail the entire upgrade if data refresh fails + } + } + + logger.info('Subscription upgrade completed successfully', { + targetPlan, + referenceId, + }) + } catch (error) { + logger.error('Failed to initiate subscription upgrade:', error) + + // Log detailed error information for debugging + if (error instanceof Error) { + console.error('Detailed error:', { + message: error.message, + stack: error.stack, + cause: error.cause, + }) + } + + throw new Error( + `Failed to upgrade subscription: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + }, + [session?.user?.id, betterAuthSubscription, loadOrganizationData] + ) + + return { handleUpgrade } +} diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index ff3cc10dc..63e2e5d1e 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -59,29 +59,19 @@ export async function updateWorkflowRunCounts(workflowId: string, runs = 1) { .limit(1) if (userStatsRecord.length === 0) { - // Create new record - await db.insert(userStats).values({ - id: crypto.randomUUID(), + console.warn('User stats record not found - should be created during onboarding', { userId: workflow.userId, - totalManualExecutions: runs, - totalApiCalls: 0, - totalWebhookTriggers: 0, - totalScheduledExecutions: 0, - totalChatExecutions: 0, - totalTokensUsed: 0, - totalCost: '0.00', + }) + return // Skip stats update if record doesn't exist + } + // Update existing record + await db + .update(userStats) + .set({ + totalManualExecutions: userStatsRecord[0].totalManualExecutions + runs, lastActive: new Date(), }) - } else { - // Update existing record - await db - .update(userStats) - .set({ - totalManualExecutions: userStatsRecord[0].totalManualExecutions + runs, - lastActive: new Date(), - }) - .where(eq(userStats.userId, workflow.userId)) - } + .where(eq(userStats.userId, workflow.userId)) } return { success: true, runsAdded: runs } diff --git a/apps/sim/scripts/test-billing-suite.ts b/apps/sim/scripts/test-billing-suite.ts deleted file mode 100644 index 78fce1738..000000000 --- a/apps/sim/scripts/test-billing-suite.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { config } from 'dotenv' -import { eq, like } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import { processDailyBillingCheck } from '@/lib/billing/core/billing' -import { requireStripeClient } from '@/lib/billing/stripe-client' -import { createLogger } from '@/lib/logs/console/logger' -import { db } from '@/db' -import { member, organization, subscription, user, userStats } from '@/db/schema' - -// Load environment variables -config() - -const logger = createLogger('BillingTestSuite') - -interface TestUser { - id: string - email: string - stripeCustomerId: string - plan: string - usage: number - overage: number -} - -interface TestOrg { - id: string - name: string - stripeCustomerId: string - plan: string - seats: number - memberCount: number - totalUsage: number - overage: number -} - -interface TestResults { - users: TestUser[] - organizations: TestOrg[] - billingResults: any -} - -/** - * Comprehensive billing test suite - * Run with: bun run test:billing:suite - */ -async function runBillingTestSuite(): Promise { - logger.info('🚀 Starting comprehensive billing test suite...') - - const results: TestResults = { - users: [], - organizations: [], - billingResults: null, - } - - try { - // 1. Create test users for each scenario - logger.info('\n📋 Creating test users...') - - // Free user (no overage billing) - const freeUser = await createTestUser('free', 10) // $10 usage on free plan - results.users.push(freeUser) - - // Pro user with no overage - const proUserNoOverage = await createTestUser('pro', 15) // $15 usage < $20 base - results.users.push(proUserNoOverage) - - // Pro user with overage - const proUserWithOverage = await createTestUser('pro', 35) // $35 usage > $20 base = $15 overage - results.users.push(proUserWithOverage) - - // Pro user with high overage - const proUserHighOverage = await createTestUser('pro', 100) // $100 usage = $80 overage - results.users.push(proUserHighOverage) - - // 2. Create test organizations - logger.info('\n🏢 Creating test organizations...') - - // Team with no overage (2 seats, 3 members, low usage) - const teamNoOverage = await createTestOrganization('team', 2, 3, 150) // 3 members, $150 total < $200 base (2 seats × $100) - results.organizations.push(teamNoOverage) - - // Team with overage (2 seats, 3 members, high usage) - const teamWithOverage = await createTestOrganization('team', 2, 3, 350) // 3 members, $350 total > $200 base = $150 overage - results.organizations.push(teamWithOverage) - - // Enterprise with overage (5 seats, 8 members, high usage) - const enterpriseWithOverage = await createTestOrganization('enterprise', 5, 8, 2000) // 8 members, $2000 total > $1500 base (5 seats × $300) = $500 overage - results.organizations.push(enterpriseWithOverage) - - // 3. Display test data summary - logger.info('\n📊 Test Data Summary:') - logger.info('===================') - - logger.info('\n👤 Individual Users:') - for (const user of results.users) { - logger.info(` ${user.plan.toUpperCase()} - ${user.email}`) - logger.info(` Usage: $${user.usage} | Overage: $${user.overage}`) - logger.info(` Customer: ${user.stripeCustomerId}`) - } - - logger.info('\n🏢 Organizations:') - for (const org of results.organizations) { - logger.info(` ${org.plan.toUpperCase()} - ${org.name}`) - logger.info( - ` Seats: ${org.seats} | Members: ${org.memberCount} | Usage: $${org.totalUsage} | Overage: $${org.overage}` - ) - logger.info(` Customer: ${org.stripeCustomerId}`) - } - - // 4. Wait for user confirmation - logger.info('\n⏸️ Test data created. Ready to run billing CRON?') - logger.info(' Press Ctrl+C to cancel, or wait 5 seconds to continue...') - await sleep(5000) - - // 5. Run the daily billing CRON - logger.info('\n🔄 Running daily billing CRON...') - const billingResult = await processDailyBillingCheck() - results.billingResults = billingResult - - // 6. Display billing results - logger.info('\n💰 Billing Results:') - logger.info('==================') - logger.info(`✅ Success: ${billingResult.success}`) - logger.info(`👤 Users processed: ${billingResult.processedUsers}`) - logger.info(`🏢 Organizations processed: ${billingResult.processedOrganizations}`) - logger.info(`💵 Total charged: $${billingResult.totalChargedAmount}`) - - if (billingResult.errors.length > 0) { - logger.error('❌ Errors:', billingResult.errors) - } - - // 7. Verify results in Stripe - logger.info('\n🔍 Verifying in Stripe...') - await verifyStripeResults(results) - - logger.info('\n✅ Test suite completed successfully!') - logger.info('\n📝 Next steps:') - logger.info('1. Check your Stripe Dashboard for invoices') - logger.info('2. Monitor webhook events in your listener') - logger.info('3. Check for email notifications (if in live mode)') - - return results - } catch (error) { - logger.error('Test suite failed', { error }) - throw error - } -} - -async function createTestUser(plan: 'free' | 'pro', usageAmount: number): Promise { - 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/socket-server/middleware/auth.ts b/apps/sim/socket-server/middleware/auth.ts index 5e631b297..a7d28175b 100644 --- a/apps/sim/socket-server/middleware/auth.ts +++ b/apps/sim/socket-server/middleware/auth.ts @@ -24,7 +24,6 @@ export async function authenticateSocket(socket: AuthenticatedSocket, next: any) hasToken: !!token, origin, referer, - allHeaders: Object.keys(socket.handshake.headers), }) if (!token) { diff --git a/apps/sim/stores/organization/store.ts b/apps/sim/stores/organization/store.ts index 7c3988089..bd0290530 100644 --- a/apps/sim/stores/organization/store.ts +++ b/apps/sim/stores/organization/store.ts @@ -167,44 +167,6 @@ export const useOrganizationStore = create()( lastSubscriptionFetched: Date.now(), }) } else { - // Check billing endpoint for enterprise subscriptions - const { hasEnterprisePlan } = get() - if (hasEnterprisePlan) { - try { - const billingResponse = await fetch('/api/billing?context=user') - if (billingResponse.ok) { - const billingData = await billingResponse.json() - if ( - billingData.success && - billingData.data.isEnterprise && - billingData.data.status - ) { - const enterpriseSubscription = { - id: `subscription_${Date.now()}`, - plan: billingData.data.plan, - status: billingData.data.status, - seats: billingData.data.seats, - referenceId: billingData.data.organizationId || 'unknown', - } - logger.info('Found enterprise subscription from billing data', { - plan: enterpriseSubscription.plan, - seats: enterpriseSubscription.seats, - }) - set({ - subscriptionData: enterpriseSubscription, - isLoadingSubscription: false, - lastSubscriptionFetched: Date.now(), - }) - return - } - } - } catch (err) { - logger.error('Error fetching enterprise subscription from billing endpoint', { - error: err, - }) - } - } - logger.warn('No active subscription found for organization', { orgId }) set({ subscriptionData: null, @@ -221,13 +183,14 @@ export const useOrganizationStore = create()( } }, - loadOrganizationBillingData: async (organizationId: string) => { + loadOrganizationBillingData: async (organizationId: string, force?: boolean) => { const state = get() if ( state.organizationBillingData && state.lastOrgBillingFetched && - Date.now() - state.lastOrgBillingFetched < CACHE_DURATION + Date.now() - state.lastOrgBillingFetched < CACHE_DURATION && + !force ) { logger.debug('Using cached organization billing data') return @@ -331,6 +294,14 @@ export const useOrganizationStore = create()( const fullOrgResponse = await client.organization.getFullOrganization() const updatedOrg = fullOrgResponse.data + logger.info('Refreshed organization data', { + orgId: updatedOrg?.id, + members: updatedOrg?.members?.length ?? 0, + invitations: updatedOrg?.invitations?.length ?? 0, + pendingInvitations: + updatedOrg?.invitations?.filter((inv: any) => inv.status === 'pending').length ?? 0, + }) + set({ activeOrganization: updatedOrg }) // Also refresh subscription data @@ -586,37 +557,6 @@ export const useOrganizationStore = create()( } }, - updateMemberUsageLimit: async (userId: string, organizationId: string, newLimit: number) => { - try { - const response = await fetch( - `/api/usage-limits?context=member&userId=${userId}&organizationId=${organizationId}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ limit: newLimit }), - } - ) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to update member usage limit') - } - - // Refresh organization billing data - await get().loadOrganizationBillingData(organizationId) - - logger.debug('Member usage limit updated successfully', { userId, newLimit }) - return { success: true } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to update member usage limit' - logger.error('Failed to update member usage limit', { error, userId, newLimit }) - return { success: false, error: errorMessage } - } - }, - // Seat management addSeats: async (newSeatCount: number) => { const { activeOrganization, subscriptionData } = get() @@ -663,7 +603,7 @@ export const useOrganizationStore = create()( } const { used: totalCount } = calculateSeatUsage(activeOrganization) - if (totalCount >= newSeatCount) { + if (totalCount > newSeatCount) { set({ error: `You have ${totalCount} active members/invitations. Please remove members or cancel invitations before reducing seats.`, }) diff --git a/apps/sim/stores/organization/types.ts b/apps/sim/stores/organization/types.ts index a5946d64c..1d7fef056 100644 --- a/apps/sim/stores/organization/types.ts +++ b/apps/sim/stores/organization/types.ts @@ -80,13 +80,16 @@ export interface OrganizationBillingData { subscriptionStatus: string totalSeats: number usedSeats: number + seatsCount: number totalCurrentUsage: number totalUsageLimit: number + minimumBillingAmount: number averageUsagePerMember: number billingPeriodStart: string | null billingPeriodEnd: string | null members?: MemberUsageData[] userRole?: string + billingBlocked?: boolean } export interface OrganizationState { @@ -133,7 +136,7 @@ export interface OrganizationState { export interface OrganizationStore extends OrganizationState { loadData: () => Promise loadOrganizationSubscription: (orgId: string) => Promise - loadOrganizationBillingData: (organizationId: string) => Promise + loadOrganizationBillingData: (organizationId: string, force?: boolean) => Promise loadUserWorkspaces: (userId?: string) => Promise refreshOrganization: () => Promise @@ -146,11 +149,6 @@ export interface OrganizationStore extends OrganizationState { 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 diff --git a/apps/sim/stores/subscription/store.ts b/apps/sim/stores/subscription/store.ts index 4278b57d2..617bca1a1 100644 --- a/apps/sim/stores/subscription/store.ts +++ b/apps/sim/stores/subscription/store.ts @@ -5,7 +5,6 @@ import { createLogger } from '@/lib/logs/console/logger' import type { BillingStatus, SubscriptionData, - SubscriptionFeatures, SubscriptionStore, UsageData, UsageLimitData, @@ -15,12 +14,6 @@ const logger = createLogger('SubscriptionStore') const CACHE_DURATION = 30 * 1000 -const defaultFeatures: SubscriptionFeatures = { - sharingEnabled: false, - multiplayerEnabled: false, - workspaceCollaborationEnabled: false, -} - const defaultUsage: UsageData = { current: 0, limit: DEFAULT_FREE_CREDITS, @@ -72,7 +65,7 @@ export const useSubscriptionStore = create()( } const result = await response.json() - const data = result.data + const data = { ...result.data, billingBlocked: result.data?.billingBlocked ?? false } // Transform dates with error handling const transformedData: SubscriptionData = { @@ -110,6 +103,7 @@ export const useSubscriptionStore = create()( })() : null, }, + billingBlocked: !!data.billingBlocked, } // Debug logging for billing periods @@ -200,53 +194,6 @@ export const useSubscriptionStore = create()( } }, - cancelSubscription: async () => { - const state = get() - if (!state.subscriptionData) { - logger.error('No subscription data available for cancellation') - return { success: false, error: 'No subscription data available' } - } - - set({ isLoading: true, error: null }) - - try { - const response = await fetch('/api/users/me/subscription/cancel', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to cancel subscription') - } - - const result = await response.json() - - logger.info('Subscription cancelled successfully', { - periodEnd: result.data.periodEnd, - cancelAtPeriodEnd: result.data.cancelAtPeriodEnd, - }) - - // Refresh subscription data to reflect cancellation status - await get().refresh() - - return { - success: true, - periodEnd: result.data.periodEnd ? new Date(result.data.periodEnd) : undefined, - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to cancel subscription' - logger.error('Failed to cancel subscription', { error }) - set({ error: errorMessage }) - return { success: false, error: errorMessage } - } finally { - set({ isLoading: false }) - } - }, - refresh: async () => { // Force refresh by clearing cache set({ lastFetched: null }) @@ -428,16 +375,14 @@ export const useSubscriptionStore = create()( } }, - getFeatures: () => { - return get().subscriptionData?.features ?? defaultFeatures - }, - getUsage: () => { return get().subscriptionData?.usage ?? defaultUsage }, getBillingStatus: (): BillingStatus => { const usage = get().getUsage() + const blocked = get().subscriptionData?.billingBlocked + if (blocked) return 'blocked' if (usage.isExceeded) return 'exceeded' if (usage.isWarning) return 'warning' return 'ok' @@ -460,10 +405,6 @@ export const useSubscriptionStore = create()( 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 diff --git a/apps/sim/stores/subscription/types.ts b/apps/sim/stores/subscription/types.ts index 3a7880ff0..c0de147d4 100644 --- a/apps/sim/stores/subscription/types.ts +++ b/apps/sim/stores/subscription/types.ts @@ -1,9 +1,3 @@ -export interface SubscriptionFeatures { - sharingEnabled: boolean - multiplayerEnabled: boolean - workspaceCollaborationEnabled: boolean -} - export interface UsageData { current: number limit: number @@ -35,11 +29,11 @@ export interface SubscriptionData { metadata: any | null stripeSubscriptionId: string | null periodEnd: Date | null - features: SubscriptionFeatures usage: UsageData + billingBlocked?: boolean } -export type BillingStatus = 'unknown' | 'ok' | 'warning' | 'exceeded' +export type BillingStatus = 'unknown' | 'ok' | 'warning' | 'exceeded' | 'blocked' export interface SubscriptionStore { subscriptionData: SubscriptionData | null @@ -54,7 +48,6 @@ export interface SubscriptionStore { usageLimitData: UsageLimitData | null }> updateUsageLimit: (newLimit: number) => Promise<{ success: boolean; error?: string }> - cancelSubscription: () => Promise<{ success: boolean; error?: string; periodEnd?: Date }> refresh: () => Promise clearError: () => void reset: () => void @@ -69,12 +62,10 @@ export interface SubscriptionStore { seats: number | null metadata: any | null } - getFeatures: () => SubscriptionFeatures getUsage: () => UsageData getBillingStatus: () => BillingStatus getRemainingBudget: () => number getDaysRemainingInPeriod: () => number | null - hasFeature: (feature: keyof SubscriptionFeatures) => boolean isAtLeastPro: () => boolean isAtLeastTeam: () => boolean canUpgrade: () => boolean diff --git a/bun.lock b/bun.lock index 79d801d91..420f0dd40 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,9 @@ "@linear/sdk": "40.0.0", "@t3-oss/env-nextjs": "0.13.4", "@vercel/analytics": "1.5.0", + "drizzle-orm": "0.44.5", "geist": "^1.4.2", + "pg": "8.16.3", "react-colorful": "5.6.1", "remark-gfm": "4.0.1", "socket.io-client": "4.8.1", @@ -1866,7 +1868,7 @@ "drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="], - "drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="], + "drizzle-orm": ["drizzle-orm@0.44.5", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-jBe37K7d8ZSKptdKfakQFdeljtu3P2Cbo7tJoJSVZADzIKOBo9IAJPOmMsH2bZl90bZgh8FQlD8BjxXA/zuBkQ=="], "duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="], @@ -3798,6 +3800,8 @@ "sim/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "sim/drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="], + "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], "sim/tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], diff --git a/package.json b/package.json index 1170e97ea..9f6de8eec 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "@linear/sdk": "40.0.0", "@t3-oss/env-nextjs": "0.13.4", "@vercel/analytics": "1.5.0", + "drizzle-orm": "0.44.5", "geist": "^1.4.2", + "pg": "8.16.3", "react-colorful": "5.6.1", "remark-gfm": "4.0.1", "socket.io-client": "4.8.1"