# Plan Limits System ## Overview The Plan Limits System enforces subscription-based usage restrictions in IGNY8. It tracks both **hard limits** (persistent throughout subscription) and **monthly limits** (reset on billing cycle). **File:** `/docs/PLAN-LIMITS.md` **Version:** 1.0.0 **Last Updated:** December 12, 2025 --- ## Architecture ### Limit Types #### Hard Limits (Never Reset) These limits persist for the lifetime of the subscription and represent total capacity: | Limit Type | Field Name | Description | Example Value | |------------|------------|-------------|---------------| | Sites | `max_sites` | Maximum number of sites per account | Starter: 2, Growth: 5, Scale: Unlimited | | Team Users | `max_users` | Maximum team members | Starter: 1, Growth: 3, Scale: 10 | | Keywords | `max_keywords` | Total keywords allowed | Starter: 500, Growth: 1000, Scale: Unlimited | | Clusters | `max_clusters` | Total clusters allowed | Starter: 50, Growth: 100, Scale: Unlimited | #### Monthly Limits (Reset on Billing Cycle) These limits reset automatically at the start of each billing period: | Limit Type | Field Name | Description | Example Value | |------------|------------|-------------|---------------| | Content Ideas | `max_content_ideas` | New ideas generated per month | Starter: 100, Growth: 300, Scale: 600 | | Content Words | `max_content_words` | Total words generated per month | Starter: 100K, Growth: 300K, Scale: 500K | | Basic Images | `max_images_basic` | Basic AI images per month | Starter: 100, Growth: 300, Scale: 500 | | Premium Images | `max_images_premium` | Premium AI images (DALL-E) per month | Starter: 20, Growth: 60, Scale: 100 | | Image Prompts | `max_image_prompts` | AI-generated prompts per month | Starter: 100, Growth: 300, Scale: 500 | --- ## Database Schema ### Plan Model Extensions **Location:** `backend/igny8_core/auth/models.py` ```python class Plan(models.Model): # ... existing fields ... # Hard Limits max_sites = IntegerField(default=2, validators=[MinValueValidator(1)]) max_users = IntegerField(default=1, validators=[MinValueValidator(1)]) max_keywords = IntegerField(default=500, validators=[MinValueValidator(1)]) max_clusters = IntegerField(default=50, validators=[MinValueValidator(1)]) # Monthly Limits max_content_ideas = IntegerField(default=100, validators=[MinValueValidator(1)]) max_content_words = IntegerField(default=100000, validators=[MinValueValidator(1)]) max_images_basic = IntegerField(default=100, validators=[MinValueValidator(1)]) max_images_premium = IntegerField(default=20, validators=[MinValueValidator(1)]) max_image_prompts = IntegerField(default=100, validators=[MinValueValidator(1)]) ``` ### PlanLimitUsage Model **Location:** `backend/igny8_core/business/billing/models.py` Tracks monthly consumption for each limit type: ```python class PlanLimitUsage(AccountBaseModel): LIMIT_TYPE_CHOICES = [ ('content_ideas', 'Content Ideas'), ('content_words', 'Content Words'), ('images_basic', 'Basic Images'), ('images_premium', 'Premium Images'), ('image_prompts', 'Image Prompts'), ] limit_type = CharField(max_length=50, choices=LIMIT_TYPE_CHOICES, db_index=True) amount_used = IntegerField(default=0, validators=[MinValueValidator(0)]) period_start = DateField() period_end = DateField() metadata = JSONField(default=dict) # Stores breakdown by site, content_id, etc. class Meta: unique_together = [['account', 'limit_type', 'period_start']] indexes = [ Index(fields=['account', 'period_start']), Index(fields=['period_end']), ] ``` **Migration:** `backend/igny8_core/modules/billing/migrations/0015_planlimitusage.py` --- ## Service Layer ### LimitService **Location:** `backend/igny8_core/business/billing/services/limit_service.py` Central service for all limit operations. #### Key Methods ##### 1. Check Hard Limit ```python LimitService.check_hard_limit(account, limit_type, additional_count=1) ``` **Purpose:** Validate if adding items would exceed hard limit **Raises:** `HardLimitExceededError` if limit exceeded **Example:** ```python try: LimitService.check_hard_limit(account, 'sites', additional_count=1) # Proceed with site creation except HardLimitExceededError as e: raise PermissionDenied(str(e)) ``` ##### 2. Check Monthly Limit ```python LimitService.check_monthly_limit(account, limit_type, amount) ``` **Purpose:** Validate if operation would exceed monthly allowance **Raises:** `MonthlyLimitExceededError` if limit exceeded **Example:** ```python try: LimitService.check_monthly_limit(account, 'content_words', amount=2500) # Proceed with content generation except MonthlyLimitExceededError as e: raise InsufficientCreditsError(str(e)) ``` ##### 3. Increment Usage ```python LimitService.increment_usage(account, limit_type, amount, metadata=None) ``` **Purpose:** Record usage after successful operation **Returns:** New total usage **Example:** ```python LimitService.increment_usage( account=account, limit_type='content_words', amount=2500, metadata={ 'content_id': 123, 'content_title': 'My Article', 'site_id': 456 } ) ``` ##### 4. Get Usage Summary ```python LimitService.get_usage_summary(account) ``` **Purpose:** Comprehensive usage report for all limits **Returns:** Dictionary with hard_limits, monthly_limits, period info **Example Response:** ```json { "account_id": 1, "account_name": "Acme Corp", "plan_name": "Growth Plan", "period_start": "2025-12-01", "period_end": "2025-12-31", "days_until_reset": 19, "hard_limits": { "sites": { "display_name": "Sites", "current": 3, "limit": 5, "remaining": 2, "percentage_used": 60 }, "keywords": { "display_name": "Keywords", "current": 750, "limit": 1000, "remaining": 250, "percentage_used": 75 } }, "monthly_limits": { "content_words": { "display_name": "Content Words", "current": 245000, "limit": 300000, "remaining": 55000, "percentage_used": 82 }, "images_basic": { "display_name": "Basic Images", "current": 120, "limit": 300, "remaining": 180, "percentage_used": 40 } } } ``` ##### 5. Reset Monthly Limits ```python LimitService.reset_monthly_limits(account) ``` **Purpose:** Reset all monthly usage at period end (called by Celery task) **Returns:** Dictionary with reset summary **Note:** Called automatically by scheduled task, not manually --- ## Enforcement Points ### 1. Site Creation **File:** `backend/igny8_core/auth/views.py` (SiteViewSet.perform_create) ```python LimitService.check_hard_limit(account, 'sites', additional_count=1) ``` ### 2. Content Generation **File:** `backend/igny8_core/business/content/services/content_generation_service.py` ```python # Check limit before generation LimitService.check_monthly_limit(account, 'content_words', amount=total_word_count) # Increment usage after successful generation LimitService.increment_usage(account, 'content_words', amount=actual_word_count) ``` ### 3. Content Save Hook **File:** `backend/igny8_core/business/content/models.py` (Content.save) Automatically increments `content_words` usage when content_html is saved: ```python def save(self, *args, **kwargs): # Auto-calculate word count if self.content_html: calculated_count = calculate_word_count(self.content_html) self.word_count = calculated_count super().save(*args, **kwargs) # Increment usage for newly generated words if new_words > 0: LimitService.increment_usage(account, 'content_words', amount=new_words) ``` ### 4. Additional Enforcement Points (To Be Implemented) Following the same pattern, add checks to: - **Keyword Import:** Check `max_keywords` before bulk import - **Clustering:** Check `max_clusters` before creating new clusters - **Idea Generation:** Check `max_content_ideas` before generating ideas - **Image Generation:** Check `max_images_basic`/`max_images_premium` before AI call --- ## Word Counting Utility **Location:** `backend/igny8_core/utils/word_counter.py` Provides accurate word counting from HTML content. ### Functions #### calculate_word_count(html_content) ```python from igny8_core.utils.word_counter import calculate_word_count word_count = calculate_word_count('

Hello world!

') # Returns: 2 ``` **Method:** 1. Strips HTML tags using BeautifulSoup 2. Fallback to regex if BeautifulSoup fails 3. Counts words (sequences of alphanumeric characters) #### format_word_count(count) ```python formatted = format_word_count(1500) # "1.5K" formatted = format_word_count(125000) # "125K" ``` #### validate_word_count_limit(html_content, limit) ```python result = validate_word_count_limit(html, limit=100000) # Returns: { # 'allowed': True, # 'word_count': 2500, # 'limit': 100000, # 'remaining': 97500, # 'would_exceed_by': 0 # } ``` --- ## Scheduled Tasks **Location:** `backend/igny8_core/tasks/plan_limits.py` ### 1. Reset Monthly Plan Limits **Task Name:** `reset_monthly_plan_limits` **Schedule:** Daily at 00:30 UTC **Purpose:** Reset monthly usage for accounts at period end **Process:** 1. Find all active accounts with subscriptions 2. Check if `current_period_end` <= today 3. Call `LimitService.reset_monthly_limits(account)` 4. Update subscription period dates 5. Log reset summary ### 2. Check Approaching Limits **Task Name:** `check_approaching_limits` **Schedule:** Daily at 09:00 UTC **Purpose:** Warn users when usage exceeds 80% threshold **Process:** 1. Find all active accounts 2. Get usage summary 3. Check if any limit >= 80% 4. Log warnings (future: send email notifications) **Celery Beat Configuration:** `backend/igny8_core/celery.py` ```python app.conf.beat_schedule = { 'reset-monthly-plan-limits': { 'task': 'reset_monthly_plan_limits', 'schedule': crontab(hour=0, minute=30), }, 'check-approaching-limits': { 'task': 'check_approaching_limits', 'schedule': crontab(hour=9, minute=0), }, } ``` --- ## API Endpoints ### Get Usage Summary **Endpoint:** `GET /api/v1/billing/usage-summary/` **Authentication:** Required (IsAuthenticatedAndActive) **Response:** Usage summary for current account **Example Request:** ```bash curl -H "Authorization: Bearer " \ /api/v1/billing/usage-summary/ ``` **Example Response:** ```json { "success": true, "message": "Usage summary retrieved successfully.", "data": { "account_id": 1, "account_name": "Acme Corp", "plan_name": "Growth Plan", "period_start": "2025-12-01", "period_end": "2025-12-31", "days_until_reset": 19, "hard_limits": { ... }, "monthly_limits": { ... } } } ``` --- ## Error Handling ### HardLimitExceededError ```python raise HardLimitExceededError( f"Sites limit exceeded. Current: 5, Limit: 5. " f"Upgrade your plan to increase this limit." ) ``` **HTTP Status:** 403 Forbidden **User Action:** Upgrade plan or delete unused resources ### MonthlyLimitExceededError ```python raise MonthlyLimitExceededError( f"Content Words limit exceeded. Used: 295000, Requested: 8000, Limit: 300000. " f"Resets on December 31, 2025. Upgrade your plan or wait for reset." ) ``` **HTTP Status:** 403 Forbidden **User Action:** Wait for reset, upgrade plan, or reduce request size --- ## Frontend Integration Guide ### TypeScript Types ```typescript interface Plan { id: number; name: string; // Hard limits max_sites: number; max_users: number; max_keywords: number; max_clusters: number; // Monthly limits max_content_ideas: number; max_content_words: number; max_images_basic: number; max_images_premium: number; max_image_prompts: number; } interface UsageSummary { account_id: number; account_name: string; plan_name: string; period_start: string; period_end: string; days_until_reset: number; hard_limits: { [key: string]: { display_name: string; current: number; limit: number; remaining: number; percentage_used: number; }; }; monthly_limits: { [key: string]: { display_name: string; current: number; limit: number; remaining: number; percentage_used: number; }; }; } ``` ### API Hook Example ```typescript // src/services/api/billing.ts export const getUsageSummary = async (): Promise => { const response = await apiClient.get('/billing/usage-summary/'); return response.data.data; }; // src/pages/Dashboard.tsx const { data: usage } = useQuery('usage-summary', getUsageSummary); ``` ### UI Components #### Usage Widget ```tsx

Usage This Month

{usage.days_until_reset} days until reset
{Object.entries(usage.monthly_limits).map(([key, data]) => (
{data.display_name}
= 80 ? 'warning' : 'primary'} /> {data.current.toLocaleString()} / {data.limit.toLocaleString()}
))}
``` #### Limit Warning Alert ```tsx {usage.monthly_limits.content_words.percentage_used >= 80 && ( ⚠️ You've used {usage.monthly_limits.content_words.percentage_used}% of your monthly word limit. Resets in {usage.days_until_reset} days. Upgrade Plan )} ``` --- ## Testing ### Manual Testing Checklist 1. **Hard Limit - Sites:** - Set plan `max_sites = 2` - Create 2 sites successfully - Attempt to create 3rd site → should fail with error 2. **Monthly Limit - Words:** - Set plan `max_content_words = 5000` - Generate content with 3000 words - Generate content with 2500 words → should fail - Check usage API shows 3000/5000 3. **Usage Increment:** - Generate content - Verify `PlanLimitUsage.amount_used` increments correctly - Check metadata contains content_id 4. **Monthly Reset:** - Manually run: `docker exec igny8_backend python manage.py shell` - Execute: ```python from igny8_core.tasks.plan_limits import reset_monthly_plan_limits reset_monthly_plan_limits() ``` - Verify usage resets to 0 - Verify new period records created 5. **Usage Summary API:** - Call GET `/api/v1/billing/usage-summary/` - Verify all limits present - Verify percentages calculated correctly ### Unit Test Example ```python # tests/test_limit_service.py def test_check_hard_limit_exceeded(): account = create_test_account(plan_max_sites=2) create_test_sites(account, count=2) with pytest.raises(HardLimitExceededError): LimitService.check_hard_limit(account, 'sites', additional_count=1) def test_increment_monthly_usage(): account = create_test_account() LimitService.increment_usage(account, 'content_words', amount=1000) usage = PlanLimitUsage.objects.get(account=account, limit_type='content_words') assert usage.amount_used == 1000 ``` --- ## Monitoring & Logs ### Key Log Messages **Successful limit check:** ``` INFO Hard limit check: sites - Current: 2, Requested: 1, Limit: 5 INFO Monthly limit check: content_words - Current: 50000, Requested: 2500, Limit: 100000 ``` **Limit exceeded:** ``` WARNING Hard limit exceeded: sites - Current: 5, Requested: 1, Limit: 5 WARNING Monthly limit exceeded: content_words - Used: 98000, Requested: 5000, Limit: 100000 ``` **Usage increment:** ``` INFO Incremented content_words usage by 2500. New total: 52500 ``` **Monthly reset:** ``` INFO Resetting limits for account 123 (Acme Corp) - period ended 2025-12-31 INFO Reset complete for account 123: New period 2026-01-01 to 2026-01-31 INFO Monthly plan limits reset task complete: 45 accounts reset, 0 errors ``` --- ## Troubleshooting ### Issue: Limits not enforcing **Check:** 1. Verify Plan has non-zero limit values: `Plan.objects.get(id=X)` 2. Check if service calling LimitService methods 3. Review logs for exceptions being caught ### Issue: Usage not incrementing **Check:** 1. Verify Content.save() executing successfully 2. Check for exceptions in logs during increment_usage 3. Query `PlanLimitUsage` table directly ### Issue: Reset task not running **Check:** 1. Celery Beat is running: `docker exec igny8_backend celery -A igny8_core inspect active` 2. Check Celery Beat schedule: `docker exec igny8_backend celery -A igny8_core inspect scheduled` 3. Review Celery logs: `docker logs igny8_celery_beat` --- ## Future Enhancements 1. **Email Notifications:** - Send warning emails at 80%, 90%, 100% thresholds - Weekly usage summary reports - Monthly reset confirmations 2. **Additional Enforcement:** - Keyword bulk import limit check - Cluster creation limit check - Idea generation limit check - Image generation limit checks 3. **Usage Analytics:** - Historical usage trends - Projection of limit exhaustion date - Recommendations for plan upgrades 4. **Soft Limits:** - Allow slight overages with warnings - Grace period before hard enforcement 5. **Admin Tools:** - Override limits for specific accounts - One-time usage bonuses - Custom limit adjustments --- ## Related Files **Models:** - `backend/igny8_core/auth/models.py` - Plan model - `backend/igny8_core/business/billing/models.py` - PlanLimitUsage model **Services:** - `backend/igny8_core/business/billing/services/limit_service.py` - LimitService - `backend/igny8_core/utils/word_counter.py` - Word counting utility **Views:** - `backend/igny8_core/auth/views.py` - Site creation enforcement - `backend/igny8_core/business/billing/views.py` - Usage summary API - `backend/igny8_core/business/content/services/content_generation_service.py` - Content generation enforcement **Tasks:** - `backend/igny8_core/tasks/plan_limits.py` - Reset and warning tasks - `backend/igny8_core/celery.py` - Celery Beat schedule **Migrations:** - `backend/igny8_core/auth/migrations/0013_plan_max_clusters_plan_max_content_ideas_and_more.py` - `backend/igny8_core/modules/billing/migrations/0015_planlimitusage.py` **Documentation:** - `CHANGELOG.md` - Version history with plan limits feature - `.cursorrules` - Development standards and versioning rules --- **End of Document**