Add project limits based on subscription tier

Co-authored-by: sharath <sharath@kortix.ai>
This commit is contained in:
Cursor Agent 2025-07-19 22:30:40 +00:00
parent ffb97b8dc8
commit d395ac1a82
9 changed files with 339 additions and 22 deletions

View File

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

View File

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

View File

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

88
docs/project-limits.md Normal file
View File

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

View File

@ -105,7 +105,7 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
{subscriptionData ? (
<>
<div className="mb-6">
<div className="mb-6 space-y-3">
<div className="rounded-lg border bg-background p-4">
<div className="flex justify-between items-center gap-4">
<span className="text-sm font-medium text-foreground/90">
@ -122,6 +122,18 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
</Button>
</div>
</div>
<div className="rounded-lg border bg-background p-4">
<div className="flex justify-between items-center gap-4">
<span className="text-sm font-medium text-foreground/90">
Projects
</span>
<span className="text-sm font-medium text-card-title">
{subscriptionData.current_project_count || 0} /{' '}
{subscriptionData.project_limit || '∞'}
</span>
</div>
</div>
</div>
{/* Plans Comparison */}
@ -149,8 +161,8 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
</>
) : (
<>
<div className="mb-6">
<div className="rounded-lg border bg-background p-4 gap-4">
<div className="mb-6 space-y-3">
<div className="rounded-lg border bg-background p-4">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground/90">
Current Plan
@ -159,7 +171,9 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
Free
</span>
</div>
</div>
<div className="rounded-lg border bg-background p-4">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground/90">
Agent Usage This Month
@ -170,6 +184,18 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
</span>
</div>
</div>
<div className="rounded-lg border bg-background p-4">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground/90">
Projects
</span>
<span className="text-sm font-medium text-card-title">
{subscriptionData?.current_project_count || 0} /{' '}
{subscriptionData?.project_limit || '3'}
</span>
</div>
</div>
</div>
{/* Plans Comparison */}

View File

@ -0,0 +1 @@
export { useProjectLimits } from './use-project-limits';

View File

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

View File

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

View File

@ -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<BillingStatusResponse> => {
}
};
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<ProjectLimitsResponse> => {
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;