diff --git a/backend/agent/api.py b/backend/agent/api.py index 72ed5526..eaef577b 100644 --- a/backend/agent/api.py +++ b/backend/agent/api.py @@ -1018,6 +1018,12 @@ async def initiate_agent_with_files( if not can_run: raise HTTPException(status_code=402, detail={"message": message, "subscription": subscription}) + # Check project limits before creating a new project + from services.billing import check_project_limits + can_create_project, project_message, project_subscription = await check_project_limits(client, account_id) + if not can_create_project: + raise HTTPException(status_code=402, detail={"message": project_message, "subscription": project_subscription}) + try: # 1. Create Project placeholder_name = f"{prompt[:30]}..." if len(prompt) > 30 else prompt diff --git a/backend/services/billing.py b/backend/services/billing.py index 1b37a553..0035ddd6 100644 --- a/backend/services/billing.py +++ b/backend/services/billing.py @@ -43,22 +43,22 @@ def get_model_pricing(model: str) -> tuple[float, float] | None: SUBSCRIPTION_TIERS = { - config.STRIPE_FREE_TIER_ID: {'name': 'free', 'minutes': 60, 'cost': 5}, - config.STRIPE_TIER_2_20_ID: {'name': 'tier_2_20', 'minutes': 120, 'cost': 20 + 5}, # 2 hours - config.STRIPE_TIER_6_50_ID: {'name': 'tier_6_50', 'minutes': 360, 'cost': 50 + 5}, # 6 hours - config.STRIPE_TIER_12_100_ID: {'name': 'tier_12_100', 'minutes': 720, 'cost': 100 + 5}, # 12 hours - config.STRIPE_TIER_25_200_ID: {'name': 'tier_25_200', 'minutes': 1500, 'cost': 200 + 5}, # 25 hours - config.STRIPE_TIER_50_400_ID: {'name': 'tier_50_400', 'minutes': 3000, 'cost': 400 + 5}, # 50 hours - config.STRIPE_TIER_125_800_ID: {'name': 'tier_125_800', 'minutes': 7500, 'cost': 800 + 5}, # 125 hours - config.STRIPE_TIER_200_1000_ID: {'name': 'tier_200_1000', 'minutes': 12000, 'cost': 1000 + 5}, # 200 hours + config.STRIPE_FREE_TIER_ID: {'name': 'free', 'minutes': 60, 'cost': 5, 'project_limit': 3}, + config.STRIPE_TIER_2_20_ID: {'name': 'tier_2_20', 'minutes': 120, 'cost': 20 + 5, 'project_limit': 10}, # 2 hours + config.STRIPE_TIER_6_50_ID: {'name': 'tier_6_50', 'minutes': 360, 'cost': 50 + 5, 'project_limit': 25}, # 6 hours + config.STRIPE_TIER_12_100_ID: {'name': 'tier_12_100', 'minutes': 720, 'cost': 100 + 5, 'project_limit': 50}, # 12 hours + config.STRIPE_TIER_25_200_ID: {'name': 'tier_25_200', 'minutes': 1500, 'cost': 200 + 5, 'project_limit': 100}, # 25 hours + config.STRIPE_TIER_50_400_ID: {'name': 'tier_50_400', 'minutes': 3000, 'cost': 400 + 5, 'project_limit': 200}, # 50 hours + config.STRIPE_TIER_125_800_ID: {'name': 'tier_125_800', 'minutes': 7500, 'cost': 800 + 5, 'project_limit': 500}, # 125 hours + config.STRIPE_TIER_200_1000_ID: {'name': 'tier_200_1000', 'minutes': 12000, 'cost': 1000 + 5, 'project_limit': 1000}, # 200 hours # Yearly tiers (same usage limits, different billing period) - config.STRIPE_TIER_2_20_YEARLY_ID: {'name': 'tier_2_20', 'minutes': 120, 'cost': 20 + 5}, # 2 hours/month, $204/year - config.STRIPE_TIER_6_50_YEARLY_ID: {'name': 'tier_6_50', 'minutes': 360, 'cost': 50 + 5}, # 6 hours/month, $510/year - config.STRIPE_TIER_12_100_YEARLY_ID: {'name': 'tier_12_100', 'minutes': 720, 'cost': 100 + 5}, # 12 hours/month, $1020/year - config.STRIPE_TIER_25_200_YEARLY_ID: {'name': 'tier_25_200', 'minutes': 1500, 'cost': 200 + 5}, # 25 hours/month, $2040/year - config.STRIPE_TIER_50_400_YEARLY_ID: {'name': 'tier_50_400', 'minutes': 3000, 'cost': 400 + 5}, # 50 hours/month, $4080/year - config.STRIPE_TIER_125_800_YEARLY_ID: {'name': 'tier_125_800', 'minutes': 7500, 'cost': 800 + 5}, # 125 hours/month, $8160/year - config.STRIPE_TIER_200_1000_YEARLY_ID: {'name': 'tier_200_1000', 'minutes': 12000, 'cost': 1000 + 5}, # 200 hours/month, $10200/year + config.STRIPE_TIER_2_20_YEARLY_ID: {'name': 'tier_2_20', 'minutes': 120, 'cost': 20 + 5, 'project_limit': 10}, # 2 hours/month, $204/year + config.STRIPE_TIER_6_50_YEARLY_ID: {'name': 'tier_6_50', 'minutes': 360, 'cost': 50 + 5, 'project_limit': 25}, # 6 hours/month, $510/year + config.STRIPE_TIER_12_100_YEARLY_ID: {'name': 'tier_12_100', 'minutes': 720, 'cost': 100 + 5, 'project_limit': 50}, # 12 hours/month, $1020/year + config.STRIPE_TIER_25_200_YEARLY_ID: {'name': 'tier_25_200', 'minutes': 1500, 'cost': 200 + 5, 'project_limit': 100}, # 25 hours/month, $2040/year + config.STRIPE_TIER_50_400_YEARLY_ID: {'name': 'tier_50_400', 'minutes': 3000, 'cost': 400 + 5, 'project_limit': 200}, # 50 hours/month, $4080/year + config.STRIPE_TIER_125_800_YEARLY_ID: {'name': 'tier_125_800', 'minutes': 7500, 'cost': 800 + 5, 'project_limit': 500}, # 125 hours/month, $8160/year + config.STRIPE_TIER_200_1000_YEARLY_ID: {'name': 'tier_200_1000', 'minutes': 12000, 'cost': 1000 + 5, 'project_limit': 1000}, # 200 hours/month, $10200/year } # Pydantic models for request/response validation @@ -81,6 +81,8 @@ class SubscriptionStatus(BaseModel): minutes_limit: Optional[int] = None cost_limit: Optional[float] = None current_usage: Optional[float] = None + project_limit: Optional[int] = None # Added project limit + current_project_count: Optional[int] = None # Added current project count # Fields for scheduled changes has_schedule: bool = False scheduled_plan_name: Optional[str] = None @@ -503,6 +505,63 @@ async def check_billing_status(client, user_id: str) -> Tuple[bool, str, Optiona return True, "OK", subscription +async def check_project_limits(client, user_id: str) -> Tuple[bool, str, Optional[Dict]]: + """ + Check if a user can create a new project based on their subscription and current project count. + + Returns: + Tuple[bool, str, Optional[Dict]]: (can_create, message, subscription_info) + """ + if config.ENV_MODE == EnvMode.LOCAL: + logger.info("Running in local development mode - project limit checks are disabled") + return True, "Local development mode - project limits disabled", { + "price_id": "local_dev", + "plan_name": "Local Development", + "project_limit": "no limit" + } + + # Get current subscription + subscription = await get_user_subscription(user_id) + + # If no subscription, they can use free tier + if not subscription: + subscription = { + 'price_id': config.STRIPE_FREE_TIER_ID, # Free tier + 'plan_name': 'free' + } + + # Extract price ID from subscription items + price_id = None + if subscription.get('items') and subscription['items'].get('data') and len(subscription['items']['data']) > 0: + price_id = subscription['items']['data'][0]['price']['id'] + else: + price_id = subscription.get('price_id', config.STRIPE_FREE_TIER_ID) + + # Get tier info - default to free tier if not found + tier_info = SUBSCRIPTION_TIERS.get(price_id) + if not tier_info: + logger.warning(f"Unknown subscription tier: {price_id}, defaulting to free tier") + tier_info = SUBSCRIPTION_TIERS[config.STRIPE_FREE_TIER_ID] + + # Get current project count for the user + project_count_result = await client.table('projects').select('project_id', count='exact').eq('account_id', user_id).execute() + current_project_count = project_count_result.count or 0 + + # Check if within project limits + project_limit = tier_info.get('project_limit', 3) # Default to 3 for free tier + if current_project_count >= project_limit: + return False, f"Project limit of {project_limit} reached. You currently have {current_project_count} projects. Please upgrade your plan to create more projects.", { + **subscription, + 'project_limit': project_limit, + 'current_project_count': current_project_count + } + + return True, "OK", { + **subscription, + 'project_limit': project_limit, + 'current_project_count': current_project_count + } + # API endpoints @router.post("/create-checkout-session") async def create_checkout_session( @@ -909,13 +968,20 @@ async def get_subscription( # Default to free tier status if no active subscription for our product free_tier_id = config.STRIPE_FREE_TIER_ID free_tier_info = SUBSCRIPTION_TIERS.get(free_tier_id) + + # Get current project count for the user + project_count_result = await client.table('projects').select('project_id', count='exact').eq('account_id', current_user_id).execute() + current_project_count = project_count_result.count or 0 + return SubscriptionStatus( status="no_subscription", plan_name=free_tier_info.get('name', 'free') if free_tier_info else 'free', price_id=free_tier_id, minutes_limit=free_tier_info.get('minutes') if free_tier_info else 0, cost_limit=free_tier_info.get('cost') if free_tier_info else 0, - current_usage=current_usage + current_usage=current_usage, + project_limit=free_tier_info.get('project_limit', 3) if free_tier_info else 3, + current_project_count=current_project_count ) # Extract current plan details @@ -927,6 +993,10 @@ async def get_subscription( logger.warning(f"User {current_user_id} subscribed to unknown price {current_price_id}. Defaulting info.") current_tier_info = {'name': 'unknown', 'minutes': 0} + # Get current project count for the user + project_count_result = await client.table('projects').select('project_id', count='exact').eq('account_id', current_user_id).execute() + current_project_count = project_count_result.count or 0 + status_response = SubscriptionStatus( status=subscription['status'], # 'active', 'trialing', etc. plan_name=subscription['plan'].get('nickname') or current_tier_info['name'], @@ -937,6 +1007,8 @@ async def get_subscription( minutes_limit=current_tier_info['minutes'], cost_limit=current_tier_info['cost'], current_usage=current_usage, + project_limit=current_tier_info.get('project_limit', 3), + current_project_count=current_project_count, has_schedule=False # Default ) @@ -998,6 +1070,31 @@ async def check_status( logger.error(f"Error checking billing status: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) +@router.get("/check-project-limits") +async def check_project_limits_endpoint( + current_user_id: str = Depends(get_current_user_id_from_jwt) +): + """Check if the user can create a new project based on their subscription and current project count.""" + try: + db = DBConnection() + client = await db.client + + can_create, message, subscription = await check_project_limits(client, current_user_id) + + return { + "can_create": can_create, + "message": message, + "subscription": { + "price_id": subscription.get('price_id', 'unknown'), + "plan_name": subscription.get('plan_name', 'unknown'), + "project_limit": subscription.get('project_limit', 'unknown'), + "current_project_count": subscription.get('current_project_count', 0) + } + } + except Exception as e: + logger.error(f"Error checking project limits: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") + @router.post("/webhook") async def stripe_webhook(request: Request): """Handle Stripe webhook events.""" diff --git a/backend/triggers/services/execution_service.py b/backend/triggers/services/execution_service.py index d1fdd6d4..03426197 100644 --- a/backend/triggers/services/execution_service.py +++ b/backend/triggers/services/execution_service.py @@ -65,6 +65,12 @@ class SessionManager: thread_id = str(uuid.uuid4()) account_id = agent_config.get('account_id') + # Check project limits before creating a new project + from services.billing import check_project_limits + can_create_project, project_message, project_subscription = await check_project_limits(client, account_id) + if not can_create_project: + raise Exception(f"Project limit exceeded: {project_message}") + placeholder_name = f"Trigger: {agent_config.get('name', 'Agent')} - {trigger_event.trigger_id[:8]}" await client.table('projects').insert({ "project_id": project_id, @@ -96,6 +102,12 @@ class SessionManager: project_id = str(uuid.uuid4()) thread_id = str(uuid.uuid4()) + # Check project limits before creating a new project + from services.billing import check_project_limits + can_create_project, project_message, project_subscription = await check_project_limits(client, account_id) + if not can_create_project: + raise Exception(f"Project limit exceeded: {project_message}") + await client.table('projects').insert({ "project_id": project_id, "account_id": account_id, diff --git a/docs/project-limits.md b/docs/project-limits.md new file mode 100644 index 00000000..fc72a414 --- /dev/null +++ b/docs/project-limits.md @@ -0,0 +1,88 @@ +# Project Limits Feature + +This document describes the project limits feature that restricts the number of projects users can create based on their subscription tier. + +## Overview + +The project limits feature ensures that users on free accounts have a reasonable limit on the number of projects they can create, while paid accounts have higher limits. This helps manage system resources and encourages users to upgrade for more projects. + +## Subscription Tiers and Project Limits + +| Subscription Tier | Project Limit | Description | +|-------------------|---------------|-------------| +| Free | 3 projects | Basic tier for new users | +| Tier 2 ($20/month) | 10 projects | Entry-level paid tier | +| Tier 6 ($50/month) | 25 projects | Mid-tier plan | +| Tier 12 ($100/month) | 50 projects | Professional tier | +| Tier 25 ($200/month) | 100 projects | Business tier | +| Tier 50 ($400/month) | 200 projects | Enterprise tier | +| Tier 125 ($800/month) | 500 projects | Large enterprise | +| Tier 200 ($1000/month) | 1000 projects | Maximum tier | + +## Implementation Details + +### Backend Changes + +1. **Billing Service (`backend/services/billing.py`)**: + - Added `project_limit` field to `SUBSCRIPTION_TIERS` configuration + - Created `check_project_limits()` function to validate project creation + - Added `/billing/check-project-limits` API endpoint + - Updated subscription status to include project limit information + +2. **Agent API (`backend/agent/api.py`)**: + - Added project limit check before creating projects in agent initiation + - Returns HTTP 402 error when limits are exceeded + +3. **Execution Service (`backend/triggers/services/execution_service.py`)**: + - Added project limit checks in `create_agent_session()` and `create_workflow_session()` + - Prevents automatic project creation when limits are reached + +### Frontend Changes + +1. **API Layer (`frontend/src/lib/api.ts`)**: + - Added `checkProjectLimits()` function + - Added `ProjectLimitsResponse` interface + - Updated `SubscriptionStatus` interface to include project limits + +2. **Project Mutations (`frontend/src/hooks/react-query/sidebar/use-project-mutations.ts`)**: + - Added project limit check before creating projects + - Shows error toast when limits are exceeded + +3. **Billing Status Component (`frontend/src/components/billing/account-billing-status.tsx`)**: + - Added project count display in billing status + - Shows current projects vs. limit + +4. **React Query Hook (`frontend/src/hooks/react-query/billing/use-project-limits.ts`)**: + - Created hook for checking project limits + - Caches results for 5 minutes + +## Error Handling + +When project limits are exceeded: + +1. **Frontend**: Shows error toast with upgrade message +2. **Backend**: Returns HTTP 402 (Payment Required) with detailed error message +3. **API Response**: Includes current project count and limit information + +## Testing + +To test the feature: + +1. Create projects up to your tier's limit +2. Attempt to create an additional project +3. Verify that appropriate error messages are shown +4. Check that billing status displays current project count + +## Local Development + +In local development mode (`ENV_MODE=LOCAL`), project limits are disabled to allow unrestricted development and testing. + +## Future Enhancements + +Potential improvements to consider: + +1. **Soft Limits**: Allow exceeding limits with warnings +2. **Project Archiving**: Allow archiving old projects to free up slots +3. **Team Limits**: Separate limits for team accounts +4. **Usage Analytics**: Track project creation patterns +5. **Dynamic Limits**: Adjust limits based on usage patterns \ No newline at end of file diff --git a/frontend/src/components/billing/account-billing-status.tsx b/frontend/src/components/billing/account-billing-status.tsx index 445b9788..8d7c447b 100644 --- a/frontend/src/components/billing/account-billing-status.tsx +++ b/frontend/src/components/billing/account-billing-status.tsx @@ -105,7 +105,7 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) { {subscriptionData ? ( <> -
+
@@ -122,6 +122,18 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
+ +
+
+ + Projects + + + {subscriptionData.current_project_count || 0} /{' '} + {subscriptionData.project_limit || '∞'} + +
+
{/* Plans Comparison */} @@ -149,8 +161,8 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) { ) : ( <> -
-
+
+
Current Plan @@ -159,7 +171,9 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) { Free
+
+
Agent Usage This Month @@ -170,6 +184,18 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
+ +
+
+ + Projects + + + {subscriptionData?.current_project_count || 0} /{' '} + {subscriptionData?.project_limit || '3'} + +
+
{/* Plans Comparison */} diff --git a/frontend/src/hooks/react-query/billing/index.ts b/frontend/src/hooks/react-query/billing/index.ts new file mode 100644 index 00000000..ea21307c --- /dev/null +++ b/frontend/src/hooks/react-query/billing/index.ts @@ -0,0 +1 @@ +export { useProjectLimits } from './use-project-limits'; \ No newline at end of file diff --git a/frontend/src/hooks/react-query/billing/use-project-limits.ts b/frontend/src/hooks/react-query/billing/use-project-limits.ts new file mode 100644 index 00000000..29569e2b --- /dev/null +++ b/frontend/src/hooks/react-query/billing/use-project-limits.ts @@ -0,0 +1,16 @@ +'use client'; + +import { createQueryHook } from '@/hooks/use-query'; +import { checkProjectLimits } from '@/lib/api'; + +export const useProjectLimits = createQueryHook( + () => checkProjectLimits(), + { + queryKey: ['project-limits'], + staleTime: 5 * 60 * 1000, // 5 minutes + errorContext: { + operation: 'check project limits', + resource: 'project limits' + } + } +); \ No newline at end of file diff --git a/frontend/src/hooks/react-query/sidebar/use-project-mutations.ts b/frontend/src/hooks/react-query/sidebar/use-project-mutations.ts index a0e761f0..ffdbb4b4 100644 --- a/frontend/src/hooks/react-query/sidebar/use-project-mutations.ts +++ b/frontend/src/hooks/react-query/sidebar/use-project-mutations.ts @@ -5,14 +5,30 @@ import { createProject, updateProject, deleteProject, - Project + Project, + checkProjectLimits } from '@/lib/api'; import { toast } from 'sonner'; import { projectKeys } from './keys'; export const useCreateProject = createMutationHook( - (data: { name: string; description: string; accountId?: string }) => - createProject(data, data.accountId), + async (data: { name: string; description: string; accountId?: string }) => { + // Check project limits before creating + try { + const limits = await checkProjectLimits(); + if (!limits.can_create) { + throw new Error(limits.message); + } + } catch (error) { + if (error instanceof Error && error.message.includes('Project limit')) { + toast.error(error.message); + throw error; + } + // For other errors, let the backend handle the limit check + } + + return createProject(data, data.accountId); + }, { onSuccess: () => { toast.success('Project created successfully'); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ea588626..23431a87 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1606,6 +1606,8 @@ export interface SubscriptionStatus { minutes_limit?: number; cost_limit?: number; current_usage?: number; + project_limit?: number; // Added project limit + current_project_count?: number; // Added current project count // Fields for scheduled changes has_schedule: boolean; scheduled_plan_name?: string; @@ -1934,6 +1936,59 @@ export const checkBillingStatus = async (): Promise => { } }; +export interface ProjectLimitsResponse { + can_create: boolean; + message: string; + subscription: { + price_id: string; + plan_name: string; + project_limit: number; + current_project_count: number; + }; +} + +export const checkProjectLimits = async (): Promise => { + try { + const supabase = createClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session?.access_token) { + throw new NoAccessTokenAvailableError(); + } + + const response = await fetch(`${API_URL}/billing/check-project-limits`, { + headers: { + Authorization: `Bearer ${session.access_token}`, + }, + }); + + if (!response.ok) { + const errorText = await response + .text() + .catch(() => 'No error details available'); + console.error( + `Error checking project limits: ${response.status} ${response.statusText}`, + errorText, + ); + throw new Error( + `Error checking project limits: ${response.statusText} (${response.status})`, + ); + } + + return response.json(); + } catch (error) { + if (error instanceof NoAccessTokenAvailableError) { + throw error; + } + + console.error('Failed to check project limits:', error); + handleApiError(error, { operation: 'check project limits', resource: 'project limits' }); + throw error; + } +}; + // Transcription API Types export interface TranscriptionResponse { text: string;