# 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