feat: add Usage Limits Panel component with usage tracking and visual indicators for limits

style: implement custom color schemes and gradients for account section, enhancing visual hierarchy
This commit is contained in:
IGNY8 VPS (Salman)
2025-12-12 13:15:15 +00:00
parent 12956ec64a
commit 6e2101d019
29 changed files with 3622 additions and 85 deletions

380
.cursorrules Normal file
View File

@@ -0,0 +1,380 @@
# IGNY8 Development Rules & Standards
**Project:** IGNY8 - AI-Powered Content Platform
**Version:** v1.0.0
**Last Updated:** December 12, 2025
---
## 📋 General Development Principles
### 1. **Always Read Documentation First**
Before making changes, consult these critical docs:
- `ARCHITECTURE-KNOWLEDGE-BASE.md` - System architecture and design patterns
- `CHANGELOG.md` - Recent changes and version history
- `IGNY8-COMPLETE-FEATURES-GUIDE.md` - Complete feature set and capabilities
- `docs/00-SYSTEM/` - Core system architecture
- `docs/10-BACKEND/` - Backend models, services, APIs
- `docs/20-API/` - API endpoint documentation
- `docs/30-FRONTEND/` - Frontend components and architecture
- `docs/40-WORKFLOWS/` - Business workflows and processes
### 2. **Maintain Consistency**
- **API Design:** Follow existing RESTful patterns in `backend/igny8_core/*/views.py`
- **Models:** Use existing base classes (`SoftDeletableModel`, `AccountBaseModel`, `SiteSectorBaseModel`)
- **Services:** Follow service pattern in `backend/igny8_core/business/*/services/`
- **AI Functions:** Use AI framework in `backend/igny8_core/ai/` (not legacy `utils/ai_processor.py`)
- **Frontend Components:** Use existing component library in `frontend/src/components/`
- **Styling:** Use TailwindCSS classes, follow existing design system in `frontend/DESIGN_SYSTEM.md`
- **State Management:** Use Zustand stores in `frontend/src/store/`
### 3. **Multi-Tenancy Rules**
- **ALWAYS scope by account:** Every query must filter by account
- **Site/Sector scoping:** Use `SiteSectorBaseModel` for site-specific data
- **Permissions:** Check permissions via `IsAuthenticatedAndActive`, `HasTenantAccess`, role-based permissions
- **No cross-tenant access:** Validate account ownership before operations
### 4. **API Endpoint Rules**
- **Use existing API structure:** All user-facing endpoints under `/api/v1/<module>/`, admin endpoints under `/api/v1/<module>/admin/`
- **No parallel API systems:** Register all endpoints in module's `urls.py`, test via Swagger at `/api/docs/` before documenting
- **Document in Swagger:** Ensure drf-spectacular auto-generates docs; verify endpoint appears at `/api/docs/` and `/api/schema/`
---
## 📝 Change Management & Versioning
alwys udpated changelog with incremental updates, as fixed aded or modified for each version update, dotn remove or modify teh exsitng version changes
### Versioning Scheme: `v<MAJOR>.<MINOR>.<PATCH>`
**Example:** v1.2.5
- `MAJOR when asked` (1.x.x): Breaking changes, major features, architecture changes
- `MAJOR` (x.2.x): New features, modules, significant enhancements
- `MINOR/PATCH` (x.x.5): Bug fixes, small improvements, refactors
### Changelog Update Rules
#### **For EVERY Change:**
1. **Update version number** in `CHANGELOG.md`
2. **Increment PATCH** (v1.0.x → v1.0.1) for:
- Bug fixes
- Small improvements
- Code refactors
- Documentation updates
- UI/UX tweaks
3. **Increment MINOR** (v1.x.0 → v1.1.0) for:
- New features
- New API endpoints
- New components
- New services
- Significant enhancements
4. **Increment MAJOR** (vx.0.0 → v2.0.0) for:
- Breaking API changes
- Database schema breaking changes
- Architecture overhauls
- Major refactors affecting multiple modules
#### **Changelog Entry Format:**
```markdown
## v1.2.5 - December 12, 2025
### Fixed
- User logout issue when switching accounts
- Payment confirmation modal amount display
### Changed
- Updated session storage from database to Redis
- Enhanced credit balance widget UI
### Added
- Plan limits enforcement system
- Monthly reset task for usage tracking
```
### **For Major Refactors:**
1. **Create detailed TODO list** before starting
2. **Document current state** in CHANGELOG
3. **Create implementation checklist** (markdown file in root or docs/)
4. **Track progress** with checklist updates
5. **Test thoroughly** before committing
6. **Update CHANGELOG** with all changes made
7. **Update version** to next MINOR or MAJOR
---
## 🏗️ Code Organization Standards
### Backend Structure
```
backend/igny8_core/
├── auth/ # Authentication, users, accounts, plans
├── business/ # Business logic services
│ ├── automation/ # Automation pipeline
│ ├── billing/ # Billing, credits, invoices
│ ├── content/ # Content generation
│ ├── integration/ # External integrations
│ ├── linking/ # Internal linking
│ ├── optimization/ # Content optimization
│ ├── planning/ # Keywords, clusters, ideas
│ └── publishing/ # WordPress publishing
├── ai/ # AI framework (NEW - use this)
├── utils/ # Utility functions
├── tasks/ # Celery tasks
└── modules/ # Legacy modules (being phased out)
```
### Frontend Structure
```
frontend/src/
├── components/ # Reusable components
├── pages/ # Page components
├── store/ # Zustand state stores
├── services/ # API service layer
├── hooks/ # Custom React hooks
├── utils/ # Utility functions
├── types/ # TypeScript types
└── marketing/ # Marketing site
```
---
## 🔧 Development Workflow
### 1. **Planning Phase**
- [ ] Read relevant documentation
- [ ] Understand existing patterns
- [ ] Create TODO list for complex changes
- [ ] Identify affected components/modules
- [ ] Plan database changes (if any)
### 2. **Implementation Phase**
- [ ] Follow existing code patterns
- [ ] Use proper base classes and mixins
- [ ] Add proper error handling
- [ ] Validate input data
- [ ] Check permissions and scope
- [ ] Write clean, documented code
- [ ] Use type hints (Python) and TypeScript types
### 3. **Testing Phase**
- [ ] Test locally with development data
- [ ] Test multi-tenancy isolation
- [ ] Test permissions and access control
- [ ] Test error cases
- [ ] Verify no breaking changes
- [ ] Check frontend-backend integration
### 4. **Documentation Phase**
- [ ] Update CHANGELOG.md
- [ ] Update version number
- [ ] Update relevant docs (if architecture/API changes)
- [ ] Add code comments for complex logic
- [ ] Update API documentation (if endpoints changed)
---
## 🎯 Specific Development Rules
### Backend Development
#### **Models:**
```python
# ALWAYS inherit from proper base classes
from igny8_core.auth.models import SiteSectorBaseModel
class MyModel(SoftDeletableModel, SiteSectorBaseModel):
# Your fields here
pass
```
#### **Services:**
```python
# Follow service pattern
class MyService:
def __init__(self):
self.credit_service = CreditService()
self.limit_service = LimitService()
def my_operation(self, account, site, **kwargs):
# 1. Validate permissions
# 2. Check limits/credits
# 3. Perform operation
# 4. Track usage
# 5. Return result
pass
```
#### **API Views:**
```python
# Use proper permission classes
class MyViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
def get_queryset(self):
# ALWAYS scope by account
return MyModel.objects.filter(
site__account=self.request.user.account
)
```
#### **Migrations:**
- Run `python manage.py makemigrations` after model changes
- Test migrations: `python manage.py migrate --plan`
- Never edit existing migrations
- Use data migrations for complex data changes
### Frontend Development
#### **Components:**
```typescript
// Use existing component library
import { Card } from '@/components/ui/card';
import Button from '@/components/ui/button/Button';
// Follow naming conventions
export default function MyComponent() {
// Component logic
}
```
#### **State Management:**
```typescript
// Use Zustand stores
import { useAuthStore } from '@/store/authStore';
const { user, account } = useAuthStore();
```
#### **API Calls:**
```typescript
// Use fetchAPI from services/api.ts
import { fetchAPI } from '@/services/api';
const data = await fetchAPI('/v1/my-endpoint/');
```
#### **Styling:**
```typescript
// Use TailwindCSS classes
<div className="p-6 bg-white dark:bg-gray-800 rounded-lg shadow">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
My Heading
</h1>
</div>
```
---
## 🚫 Common Pitfalls to Avoid
### **DON'T:**
- ❌ Skip account scoping in queries
- ❌ Use legacy AI processor (`utils/ai_processor.py`) - use `ai/` framework
- ❌ Hardcode values - use settings or constants
- ❌ Forget error handling
- ❌ Skip permission checks
- ❌ Create duplicate components - reuse existing
- ❌ Use inline styles - use TailwindCSS
- ❌ Forget to update CHANGELOG
- ❌ Use workarounds - fix the root cause
- ❌ Skip migrations after model changes
### **DO:**
- ✅ Read documentation before coding
- ✅ Follow existing patterns
- ✅ Use proper base classes
- ✅ Check permissions and limits
- ✅ Handle errors gracefully
- ✅ Return valid errors, not fallbacks
- ✅ Update CHANGELOG for every change
- ✅ Test multi-tenancy isolation
- ✅ Use TypeScript types
- ✅ Write clean, documented code
---
## 🔍 Code Review Checklist
Before committing code, verify:
- [ ] Follows existing code patterns
- [ ] Properly scoped by account/site
- [ ] Permissions checked
- [ ] Error handling implemented
- [ ] No breaking changes
- [ ] CHANGELOG.md updated
- [ ] Version number incremented
- [ ] Documentation updated (if needed)
- [ ] Tested locally
- [ ] No console errors or warnings
- [ ] TypeScript types added/updated
- [ ] Migrations created (if model changes)
---
## 📚 Key Architecture Concepts
### **Credit System:**
- All AI operations cost credits
- Check credits before operation: `CreditService.check_credits()`
- Deduct after operation: `CreditService.deduct_credits()`
- Track in `CreditUsageLog` table
### **Limit System:**
- Hard limits: Persistent (sites, users, keywords, clusters)
- Monthly limits: Reset on billing cycle (ideas, words, images)
- Track in `PlanLimitUsage` table
- Check before operation: `LimitService.check_limit()`
### **AI Framework:**
- Use `ai/engine.py` for AI operations
- Use `ai/functions/` for specific AI tasks
- Use `ai/models.py` for tracking
- Don't use legacy `utils/ai_processor.py`
### **Multi-Tenancy:**
- Every request has `request.user.account`
- All models scope by account directly or via site
- Use `AccountBaseModel` or `SiteSectorBaseModel`
- Validate ownership before mutations
---
## 🎨 Design System
### **Colors:**
- Primary: Blue (#0693e3)
- Success: Green (#0bbf87)
- Error: Red (#ef4444)
- Warning: Yellow (#f59e0b)
- Info: Blue (#3b82f6)
### **Typography:**
- Headings: font-bold
- Body: font-normal
- Small text: text-sm
- Large text: text-lg, text-xl, text-2xl
### **Spacing:**
- Padding: p-4, p-6 (standard)
- Margin: mt-4, mb-6 (standard)
- Gap: gap-4, gap-6 (standard)
### **Components:**
- Card: `<Card>` with padding and shadow
- Button: `<Button>` with variants (primary, secondary, danger)
- Input: `<Input>` with proper validation
- Badge: `<Badge>` with color variants
---
## 📞 Support & Questions
- Architecture questions → Check `ARCHITECTURE-KNOWLEDGE-BASE.md`
- Feature questions → Check `IGNY8-COMPLETE-FEATURES-GUIDE.md`
- API questions → Check `docs/20-API/`
- Recent changes → Check `CHANGELOG.md`
---
**Remember:** Quality over speed. Take time to understand existing patterns before implementing new features.

View File

@@ -1,4 +1,226 @@
# Tenancy Change Log - December 9, 2025 # IGNY8 Change Log
**Current Version:** v1.0.1
**Last Updated:** December 12, 2025
---
## v1.0.1 - December 12, 2025
### Fixed
- Usage summary endpoint routing - added direct URL path for `/api/v1/billing/usage-summary/` as standalone user-facing endpoint (separated from admin-only `BillingViewSet`)
- Endpoint accessibility issue - properly registered `get_usage_summary` function view in billing URLs
### Added
- **Frontend Plan Limits Display**: Complete UI integration for usage tracking and limits
- `UsageLimitsPanel` component - visual progress bars for all plan limits with color-coded status (blue/yellow/red)
- Plan Limits tab in Usage Analytics page (now default/first tab)
- Limit display in pricing table comparison with "Unlimited" formatting for high values
- Days until reset counter for monthly limits
- Upgrade CTA when approaching limits
- **IGNY8 Brand Styling**: Custom CSS design system for account section (`account-colors.css`)
- Brand gradients (primary blue, success green, warning amber, danger red, purple, teal)
- Enhanced card variants with top color bars
- Animated progress bars with shimmer effects
- Stat numbers with gradient text effects
- Custom badges and plan cards
- Billing tables with hover effects
- Dark mode support throughout
### Changed
- Usage Analytics page tab order - Plan Limits now first tab, followed by Credit Usage, Credit Balance, API Usage, Cost Breakdown
- Pricing table enhanced to display all plan limits (sites, users, words/month, ideas/month, images/month, credits/month)
- Plans and Billing page now passes complete limit data to pricing table component
### Documentation
- Added API endpoint rules to `.cursorrules` - enforce using existing `/api/v1/` structure, test via `/api/docs/` Swagger, no parallel API systems
- Updated `PLAN-LIMITS.md` with correct endpoint path documentation
---
## v1.0.0 - December 12, 2025 (Production Release)
### 🎉 INITIAL PRODUCTION RELEASE
This marks the first production-ready release of IGNY8, featuring a complete multi-tenant AI-powered content platform.
### 🆕 Latest Additions (v1.0.0 Final)
**Plan Limits System** - Implemented comprehensive usage limits tied to subscription plans:
- **Hard Limits** (persistent, never reset):
- `max_sites`: Maximum number of sites per account
- `max_users`: Maximum team members
- `max_keywords`: Total keywords allowed
- `max_clusters`: Total clusters allowed
- **Monthly Limits** (reset on billing cycle):
- `max_content_ideas`: New content ideas per month
- `max_content_words`: Words generated per month
- `max_images_basic`: Basic AI images per month
- `max_images_premium`: Premium AI images per month
- `max_image_prompts`: Image prompts per month
- **Enforcement Features**:
- Pre-operation validation (blocks exceeding actions)
- Real-time usage tracking and incrementation
- Automatic monthly reset via Celery Beat task
- Usage summary API endpoint (`/api/v1/billing/usage-summary/`)
- Warning notifications at 80% threshold
- Clear error messages with upgrade prompts
- **Technical Implementation**:
- `PlanLimitUsage` model tracks monthly consumption
- `LimitService` handles all check/increment/reset operations
- `word_counter.py` utility for accurate HTML word counting
- Integrated into Site creation, content generation, image generation
- Celery scheduled tasks: `reset_monthly_plan_limits` (daily), `check_approaching_limits` (daily)
### ✨ Core Features Implemented
**Multi-Tenancy System:**
- Complete account isolation with row-level security
- Multi-site management per account
- Sector-based content organization
- Role-based access control (Owner, Admin, Editor, Viewer)
- Session management with Redis backend
- JWT authentication with refresh tokens
**Planner Module:**
- Bulk keyword import (CSV, unlimited rows)
- AI-powered keyword clustering (GPT-4)
- Global seed keyword database (50+ industries)
- Content idea generation from clusters
- Search intent classification
- Advanced filtering and search
**Writer Module:**
- AI content generation (GPT-4, customizable word counts)
- Task management and queuing
- Content taxonomy system (categories, tags)
- HTML content editor integration
- SEO metadata management
- Status workflow (draft, review, published)
**Image Generation:**
- Dual AI providers (DALL-E 3, Runware)
- Featured and in-article images
- Smart prompt extraction from content
- Multiple image sizes and formats
- Automatic alt text generation
- Batch image processing
**Automation Pipeline:**
- 7-stage automation (keywords → clusters → ideas → tasks → content → prompts → images)
- Scheduled runs (daily, weekly, monthly)
- Configurable batch sizes and delays
- Credit estimation and buffering
- Pause/resume functionality
- Detailed activity logging
**Internal Linking:**
- AI-powered link candidate discovery
- Relevance scoring and smart injection
- Context-aware anchor text
- Bi-directional linking
- Link density control
**Content Optimization:**
- 15+ factor SEO analysis
- Readability scoring
- AI-powered content enhancement
- Before/after comparison
- Batch optimization support
**WordPress Integration:**
- One-click publishing to WordPress
- Bidirectional status sync
- Taxonomy synchronization
- Featured image upload
- SEO metadata sync
- Multiple site support
- API key authentication
**Billing & Credits:**
- Usage-based credit system
- Plan-based hard limits (sites, users, keywords, clusters)
- Monthly usage limits (content ideas, words, images, prompts)
- Automatic limit enforcement with clear error messages
- Real-time usage tracking and reporting
- Monthly limit reset on billing cycle
- Usage warning notifications
- Multiple payment methods (Bank Transfer, Mobile Wallet, Stripe*, PayPal*)
- Invoice generation and management
- Credit transaction tracking
- Multi-currency support (8 currencies)
- Manual payment approval workflow
**Enterprise Features:**
- Team collaboration and user management
- Activity logging and audit trails
- API access with rate limiting
- Comprehensive analytics and reporting
- Data export capabilities
### 🏗️ Technical Stack
**Backend:**
- Django 5.x + Django REST Framework
- PostgreSQL 14+ (production)
- Redis 7+ (cache and sessions)
- Celery + Celery Beat (task queue)
- JWT authentication
- Docker containerization
**Frontend:**
- React 19 + TypeScript
- Vite 6 (build tool)
- Zustand 5 (state management)
- TailwindCSS 4 (styling)
- React Router 7 (routing)
- Custom component library
**AI Integration:**
- OpenAI GPT-4, GPT-4 Turbo, GPT-3.5 Turbo
- OpenAI DALL-E 3, DALL-E 2
- Runware image generation
### 📊 Platform Statistics
- 50+ backend API endpoints
- 100+ React components
- 15+ database models
- 20+ Celery tasks
- Support for 8 currencies
- 50+ industry templates
### 🔒 Security & Compliance
- Encrypted data at rest and in transit
- GDPR compliance ready
- Session integrity validation
- CORS and CSRF protection
- SQL injection prevention
- Rate limiting per scope
### 📚 Documentation
- Complete API documentation (OpenAPI 3.0)
- Frontend component library docs
- Admin guides and workflows
- Multi-tenancy architecture docs
- Payment workflow guides
### ⚠️ Known Limitations
- Stripe integration disabled (pending production credentials)
- PayPal integration disabled (pending production credentials)
- Some analytics features in development
---
## Tenancy Change Log - December 9, 2025
## Summary ## Summary
This document tracks all changes made to the multi-tenancy system during the current staging session and the last 2 commits (4d13a570 and 72d0b6b0). This document tracks all changes made to the multi-tenancy system during the current staging session and the last 2 commits (4d13a570 and 72d0b6b0).

View File

@@ -0,0 +1,465 @@
# Plan Limits Implementation Checklist
**Version:** v1.1.0 (Minor version - new feature)
**Started:** December 12, 2025
**Status:** In Progress
---
## 📋 Overview
Implementing comprehensive plan limit system to enforce pricing tier limits for:
- Hard limits (persistent): sites, users, keywords, clusters
- Monthly limits (reset on billing cycle): content ideas, words, images, prompts
---
## Phase 1: Backend Setup ✅ / ❌
### 1.1 Plan Model Updates
- [ ] Add `max_keywords` field to Plan model
- [ ] Add `max_clusters` field to Plan model
- [ ] Add `max_content_ideas` field to Plan model
- [ ] Add `max_content_words` field to Plan model
- [ ] Add `max_images_basic` field to Plan model
- [ ] Add `max_images_premium` field to Plan model
- [ ] Add `max_image_prompts` field to Plan model
- [ ] Add help text and validators for all fields
- [ ] Update `__str__` method if needed
**File:** `backend/igny8_core/auth/models.py`
### 1.2 PlanLimitUsage Model Creation
- [ ] Create new `PlanLimitUsage` model
- [ ] Add fields: account, limit_type, amount_used, period_start, period_end
- [ ] Add Meta class with db_table, indexes, unique_together
- [ ] Add `__str__` method
- [ ] Add helper methods: `is_current_period()`, `remaining_allowance()`
**File:** `backend/igny8_core/business/billing/models.py`
### 1.3 Database Migration
- [ ] Run `makemigrations` command
- [ ] Review generated migration
- [ ] Test migration with `migrate --plan`
- [ ] Run migration `migrate`
- [ ] Verify schema in database
**Command:** `python manage.py makemigrations && python manage.py migrate`
### 1.4 Data Seeding
- [ ] Create data migration for existing plans
- [ ] Populate limit fields with default values (Starter: 2/1/100K, Growth: 5/3/300K, Scale: Unlimited/5/500K)
- [ ] Create initial PlanLimitUsage records for existing accounts
- [ ] Calculate current usage from existing data
**File:** `backend/igny8_core/business/billing/migrations/00XX_seed_plan_limits.py`
### 1.5 Word Counter Utility
- [ ] Create `word_counter.py` utility
- [ ] Implement `calculate_word_count(html_content)` function
- [ ] Strip HTML tags using BeautifulSoup or regex
- [ ] Handle edge cases (empty content, None, malformed HTML)
- [ ] Add unit tests
**File:** `backend/igny8_core/utils/word_counter.py`
### 1.6 Content Model Auto-Calculation
- [ ] Update Content model `save()` method
- [ ] Auto-calculate `word_count` when `content_html` changes
- [ ] Use `word_counter.calculate_word_count()`
- [ ] Test with sample content
**File:** `backend/igny8_core/business/content/models.py`
### 1.7 LimitService Creation
- [ ] Create `limit_service.py`
- [ ] Implement `check_hard_limit(account, limit_type)` - sites, users, keywords, clusters
- [ ] Implement `check_monthly_limit(account, limit_type, amount)` - ideas, words, images, prompts
- [ ] Implement `increment_usage(account, limit_type, amount, metadata)`
- [ ] Implement `get_usage_summary(account)` - current usage stats
- [ ] Implement `get_current_period(account)` - get billing period
- [ ] Create custom exceptions: `HardLimitExceededError`, `MonthlyLimitExceededError`
- [ ] Add logging for all operations
- [ ] Add unit tests
**File:** `backend/igny8_core/business/billing/services/limit_service.py`
### 1.8 Update PlanSerializer
- [ ] Add all new limit fields to `PlanSerializer.Meta.fields`
- [ ] Test serialization
- [ ] Verify API response includes new fields
**File:** `backend/igny8_core/auth/serializers.py`
### 1.9 Create LimitUsageSerializer
- [ ] Create `PlanLimitUsageSerializer`
- [ ] Include all fields
- [ ] Add computed fields: remaining, percentage_used
**File:** `backend/igny8_core/business/billing/serializers.py`
---
## Phase 2: Backend Enforcement ✅ / ❌
### 2.1 Hard Limit Enforcement - Sites
- [ ] Update `SiteViewSet.create()` to check `max_sites`
- [ ] Use `LimitService.check_hard_limit(account, 'sites')`
- [ ] Return proper error message if limit exceeded
- [ ] Test with different plan limits
**File:** `backend/igny8_core/auth/views.py`
### 2.2 Hard Limit Enforcement - Users
- [ ] Update user invitation logic to check `max_users`
- [ ] Use `LimitService.check_hard_limit(account, 'users')`
- [ ] Return proper error message if limit exceeded
**File:** `backend/igny8_core/auth/views.py` (UserViewSet or invitation endpoint)
### 2.3 Hard Limit Enforcement - Keywords
- [ ] Update keyword creation/import to check `max_keywords`
- [ ] Check before bulk import
- [ ] Check before individual creation
- [ ] Use `LimitService.check_hard_limit(account, 'keywords')`
**File:** `backend/igny8_core/business/planning/views.py`
### 2.4 Hard Limit Enforcement - Clusters
- [ ] Update clustering service to check `max_clusters`
- [ ] Check before AI clustering operation
- [ ] Use `LimitService.check_hard_limit(account, 'clusters')`
**File:** `backend/igny8_core/business/planning/services/clustering_service.py`
### 2.5 Monthly Limit Enforcement - Content Ideas
- [ ] Update idea generation service
- [ ] Check `max_content_ideas` before generation
- [ ] Increment usage after successful generation
- [ ] Use `LimitService.check_monthly_limit()` and `increment_usage()`
**File:** `backend/igny8_core/business/planning/services/idea_service.py` or similar
### 2.6 Monthly Limit Enforcement - Content Words
- [ ] Update content generation service
- [ ] Check `max_content_words` before generation
- [ ] Use `Content.word_count` for actual usage
- [ ] Increment usage after content created
- [ ] Sum word counts for batch operations
**File:** `backend/igny8_core/business/content/services/content_generation_service.py`
### 2.7 Monthly Limit Enforcement - Images
- [ ] Update image generation service
- [ ] Check `max_images_basic` or `max_images_premium` based on model
- [ ] Increment usage after image created
- [ ] Track basic vs premium separately
**File:** `backend/igny8_core/business/content/services/image_service.py` or similar
### 2.8 Monthly Limit Enforcement - Image Prompts
- [ ] Update image prompt extraction
- [ ] Check `max_image_prompts` before extraction
- [ ] Increment usage after prompts extracted
**File:** `backend/igny8_core/business/content/services/` (wherever prompts are extracted)
### 2.9 Automation Pipeline Integration
- [ ] Update automation to check limits before each stage
- [ ] Show limit warnings in pre-run estimation
- [ ] Stop automation if limit would be exceeded
- [ ] Log limit checks in activity log
**File:** `backend/igny8_core/business/automation/services/`
---
## Phase 3: Monthly Reset Task ✅ / ❌
### 3.1 Celery Task Creation
- [ ] Create `reset_monthly_plan_limits()` task
- [ ] Find accounts with billing period ending today
- [ ] Reset PlanLimitUsage records
- [ ] Create new records for new period
- [ ] Log reset operations
- [ ] Handle errors gracefully
**File:** `backend/igny8_core/tasks/billing.py`
### 3.2 Celery Beat Schedule
- [ ] Add task to `CELERY_BEAT_SCHEDULE`
- [ ] Set to run daily at midnight UTC
- [ ] Test task execution
**File:** `backend/igny8_core/celery.py`
### 3.3 Manual Reset Capability
- [ ] Create admin action to manually reset limits
- [ ] Add to `PlanLimitUsageAdmin`
- [ ] Test manual reset
**File:** `backend/igny8_core/business/billing/admin.py`
---
## Phase 4: API Endpoints ✅ / ❌
### 4.1 Limits Usage Endpoint
- [ ] Create `/api/v1/billing/limits/usage/` endpoint
- [ ] Return current usage for all limit types
- [ ] Return remaining allowance
- [ ] Return days until reset
- [ ] Include plan limits for reference
- [ ] Test endpoint
**File:** `backend/igny8_core/business/billing/views.py`
### 4.2 Limits History Endpoint (Optional)
- [ ] Create `/api/v1/billing/limits/history/` endpoint
- [ ] Return historical usage data
- [ ] Support date range filtering
- [ ] Test endpoint
**File:** `backend/igny8_core/business/billing/views.py`
---
## Phase 5: Frontend Updates ✅ / ❌
### 5.1 Update Plan Interface
- [ ] Add limit fields to Plan interface in `billing.api.ts`
- [ ] Add limit fields to Plan interface in `Settings/Plans.tsx`
- [ ] Add limit fields to Plan interface in `SignUpFormUnified.tsx`
- [ ] Add limit fields to Plan interface in `AuthPages/SignUp.tsx`
- [ ] Ensure consistency across all 4 locations
**Files:** Multiple interface definitions
### 5.2 Create PlanLimitsWidget Component
- [ ] Create `PlanLimitsWidget.tsx` component
- [ ] Display hard limits (Sites, Users, Keywords, Clusters) with counts
- [ ] Display monthly limits with progress bars
- [ ] Show days until reset
- [ ] Fetch from `/api/v1/billing/limits/usage/`
- [ ] Add refresh capability
- [ ] Style with existing design system
**File:** `frontend/src/components/dashboard/PlanLimitsWidget.tsx`
### 5.3 Update Dashboard
- [ ] Add `PlanLimitsWidget` to dashboard
- [ ] Position alongside CreditBalanceWidget
- [ ] Test responsive layout
**File:** `frontend/src/pages/Dashboard/Dashboard.tsx` or similar
### 5.4 Update Settings/Plans Page
- [ ] Display all limit fields in plan cards
- [ ] Format numbers (1,000 / 100K / Unlimited)
- [ ] Update `extractFeatures()` function
- [ ] Test plan display
**File:** `frontend/src/pages/Settings/Plans.tsx`
### 5.5 Update Usage Page
- [ ] Add limits section to Usage page
- [ ] Display current usage vs limits
- [ ] Show limit types (hard vs monthly)
- [ ] Add progress bars for monthly limits
- [ ] Test with real data
**File:** `frontend/src/pages/Billing/Usage.tsx`
### 5.6 Create Limit Exceeded Modal
- [ ] Create `LimitExceededModal.tsx` component
- [ ] Show when limit is reached
- [ ] Display current usage and limit
- [ ] Show upgrade options
- [ ] Link to billing page
- [ ] Style with existing modal pattern
**File:** `frontend/src/components/billing/LimitExceededModal.tsx`
### 5.7 Add Limit Guards
- [ ] Check limits before operations (optional - server-side is primary)
- [ ] Show limit exceeded modal on API error
- [ ] Handle `HardLimitExceededError` and `MonthlyLimitExceededError`
- [ ] Display user-friendly messages
**Files:** Various page components
---
## Phase 6: Admin & Testing ✅ / ❌
### 6.1 Django Admin for PlanLimitUsage
- [ ] Create `PlanLimitUsageAdmin` class
- [ ] Add list display fields
- [ ] Add filters (account, limit_type, period)
- [ ] Add search fields
- [ ] Add readonly fields (created_at, updated_at)
- [ ] Add custom actions (reset, export)
- [ ] Register admin
**File:** `backend/igny8_core/business/billing/admin.py`
### 6.2 Update Plan Admin
- [ ] Add new limit fields to `PlanAdmin`
- [ ] Group fields logically (Hard Limits, Monthly Limits)
- [ ] Add inline for related limit usage (optional)
- [ ] Test admin interface
**File:** `backend/igny8_core/auth/admin.py`
### 6.3 Backend Testing
- [ ] Test hard limit checks
- [ ] Test monthly limit checks
- [ ] Test limit increment
- [ ] Test monthly reset
- [ ] Test word count calculation
- [ ] Test API endpoints
- [ ] Test error handling
### 6.4 Frontend Testing
- [ ] Test plan display with limits
- [ ] Test limits widget
- [ ] Test usage page
- [ ] Test limit exceeded modal
- [ ] Test responsive design
- [ ] Test with different plan tiers
### 6.5 Integration Testing
- [ ] Test complete workflow: create content → check limits → increment usage
- [ ] Test monthly reset → verify limits reset
- [ ] Test upgrade plan → verify new limits apply
- [ ] Test limit exceeded → verify operation blocked
- [ ] Test across different accounts and sites
---
## Phase 7: Documentation ✅ / ❌
### 7.1 Update CHANGELOG
- [ ] Document all changes
- [ ] List new fields added
- [ ] List new services created
- [ ] List API endpoints added
- [ ] List frontend components added
- [ ] Update to v1.1.0
**File:** `CHANGELOG.md`
### 7.2 Update API Documentation
- [ ] Document `/api/v1/billing/limits/usage/` endpoint
- [ ] Document error responses
- [ ] Add examples
**File:** `docs/20-API/BILLING-ENDPOINTS.md`
### 7.3 Update Feature Guide
- [ ] Document plan limits feature
- [ ] Document limit types
- [ ] Document monthly reset
- [ ] Add to features list
**File:** `IGNY8-COMPLETE-FEATURES-GUIDE.md`
---
## Phase 8: Deployment ✅ / ❌
### 8.1 Pre-Deployment Checklist
- [ ] All migrations created and tested
- [ ] All tests passing
- [ ] No console errors in frontend
- [ ] CHANGELOG updated
- [ ] Version bumped to v1.1.0
- [ ] Code reviewed
### 8.2 Deployment Steps
- [ ] Backup database
- [ ] Run migrations
- [ ] Deploy backend
- [ ] Deploy frontend
- [ ] Verify limits working
- [ ] Monitor for errors
### 8.3 Post-Deployment Validation
- [ ] Test limit checks work
- [ ] Test usage tracking works
- [ ] Test monthly reset (wait or trigger manually)
- [ ] Verify no existing functionality broken
- [ ] Monitor error logs
---
## 📊 Progress Summary
**Total Tasks:** ~80
**Completed:** 2
**In Progress:** 1
**Not Started:** 77
**Estimated Time:** 8-12 hours
**Started:** December 12, 2025
**Target Completion:** December 13, 2025
---
## 🔍 Testing Scenarios
### Scenario 1: Hard Limit - Sites
1. Account with Starter plan (max 2 sites)
2. Create 2 sites successfully
3. Try to create 3rd site → should fail with limit error
### Scenario 2: Monthly Limit - Content Words
1. Account with Starter plan (100K words/month)
2. Generate content totaling 99K words
3. Try to generate 2K words → should fail
4. Wait for monthly reset
5. Generate 2K words → should succeed
### Scenario 3: Monthly Reset
1. Account at end of billing period
2. Has used 90% of limits
3. Run reset task
4. Verify usage reset to 0
5. Verify new period created
### Scenario 4: Plan Upgrade
1. Account on Starter plan
2. Reached 100% of limit
3. Upgrade to Growth plan
4. Verify new limits apply
5. Perform operation → should succeed
---
## ⚠️ Known Risks & Mitigation
**Risk 1:** Breaking existing content generation
**Mitigation:** Test thoroughly, use feature flags if needed
**Risk 2:** Word count inconsistency
**Mitigation:** Use single source (Content.word_count), standardize calculation
**Risk 3:** Monthly reset errors
**Mitigation:** Add error handling, logging, manual reset capability
**Risk 4:** Performance impact of limit checks
**Mitigation:** Optimize queries, add database indexes, cache plan data
---
## 📝 Notes
- Use `Content.word_count` as single source of truth for word counting
- Ignore `estimated_word_count` in Ideas and `word_count` in Tasks for limit tracking
- Hard limits check COUNT from database
- Monthly limits track usage in PlanLimitUsage table
- Reset task must be idempotent (safe to run multiple times)
- All limit checks happen server-side (frontend is informational only)
- Use proper error classes for different limit types
- Log all limit operations for debugging

View File

@@ -111,17 +111,26 @@ class AccountAdminForm(forms.ModelForm):
@admin.register(Plan) @admin.register(Plan)
class PlanAdmin(admin.ModelAdmin): class PlanAdmin(admin.ModelAdmin):
"""Plan admin - Global, no account filtering needed""" """Plan admin - Global, no account filtering needed"""
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'included_credits', 'is_active'] list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_content_words', 'included_credits', 'is_active']
list_filter = ['is_active', 'billing_cycle'] list_filter = ['is_active', 'billing_cycle', 'is_internal']
search_fields = ['name', 'slug'] search_fields = ['name', 'slug']
readonly_fields = ['created_at'] readonly_fields = ['created_at']
fieldsets = ( fieldsets = (
('Plan Info', { ('Plan Info', {
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active') 'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active', 'is_internal')
}), }),
('Account Management Limits', { ('Account Management Limits', {
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles') 'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles'),
'description': 'Persistent limits for account-level resources'
}),
('Hard Limits (Persistent)', {
'fields': ('max_keywords', 'max_clusters'),
'description': 'Total allowed - never reset'
}),
('Monthly Limits (Reset on Billing Cycle)', {
'fields': ('max_content_ideas', 'max_content_words', 'max_images_basic', 'max_images_premium', 'max_image_prompts'),
'description': 'Monthly allowances - reset at billing cycle'
}), }),
('Billing & Credits', { ('Billing & Credits', {
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month') 'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')

View File

@@ -0,0 +1,49 @@
# Generated by Django 5.2.8 on 2025-12-12 11:26
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0012_fix_subscription_constraints'),
]
operations = [
migrations.AddField(
model_name='plan',
name='max_clusters',
field=models.IntegerField(default=100, help_text='Maximum AI keyword clusters allowed (hard limit)', validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='max_content_ideas',
field=models.IntegerField(default=300, help_text='Maximum AI content ideas per month', validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='max_content_words',
field=models.IntegerField(default=100000, help_text='Maximum content words per month (e.g., 100000 = 100K words)', validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='max_image_prompts',
field=models.IntegerField(default=300, help_text='Maximum image prompts per month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_images_basic',
field=models.IntegerField(default=300, help_text='Maximum basic AI images per month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_images_premium',
field=models.IntegerField(default=60, help_text='Maximum premium AI images per month (DALL-E)', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_keywords',
field=models.IntegerField(default=1000, help_text='Maximum total keywords allowed (hard limit)', validators=[django.core.validators.MinValueValidator(1)]),
),
]

View File

@@ -0,0 +1,49 @@
# Generated by Django 5.2.8 on 2025-12-12 12:24
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0013_plan_max_clusters_plan_max_content_ideas_and_more'),
]
operations = [
migrations.AddField(
model_name='account',
name='usage_content_ideas',
field=models.IntegerField(default=0, help_text='Content ideas generated this month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='account',
name='usage_content_words',
field=models.IntegerField(default=0, help_text='Content words generated this month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='account',
name='usage_image_prompts',
field=models.IntegerField(default=0, help_text='Image prompts this month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='account',
name='usage_images_basic',
field=models.IntegerField(default=0, help_text='Basic AI images this month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='account',
name='usage_images_premium',
field=models.IntegerField(default=0, help_text='Premium AI images this month', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='account',
name='usage_period_end',
field=models.DateTimeField(blank=True, help_text='Current billing period end', null=True),
),
migrations.AddField(
model_name='account',
name='usage_period_start',
field=models.DateTimeField(blank=True, help_text='Current billing period start', null=True),
),
]

View File

@@ -106,6 +106,15 @@ class Account(SoftDeletableModel):
billing_country = models.CharField(max_length=2, blank=True, help_text="ISO 2-letter country code") billing_country = models.CharField(max_length=2, blank=True, help_text="ISO 2-letter country code")
tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number") tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number")
# Monthly usage tracking (reset on billing cycle)
usage_content_ideas = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content ideas generated this month")
usage_content_words = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content words generated this month")
usage_images_basic = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Basic AI images this month")
usage_images_premium = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Premium AI images this month")
usage_image_prompts = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Image prompts this month")
usage_period_start = models.DateTimeField(null=True, blank=True, help_text="Current billing period start")
usage_period_end = models.DateTimeField(null=True, blank=True, help_text="Current billing period end")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -192,6 +201,45 @@ class Plan(models.Model):
max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors") max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors")
max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles") max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles")
# Hard Limits (Persistent - user manages within limit)
max_keywords = models.IntegerField(
default=1000,
validators=[MinValueValidator(1)],
help_text="Maximum total keywords allowed (hard limit)"
)
max_clusters = models.IntegerField(
default=100,
validators=[MinValueValidator(1)],
help_text="Maximum AI keyword clusters allowed (hard limit)"
)
# Monthly Limits (Reset on billing cycle)
max_content_ideas = models.IntegerField(
default=300,
validators=[MinValueValidator(1)],
help_text="Maximum AI content ideas per month"
)
max_content_words = models.IntegerField(
default=100000,
validators=[MinValueValidator(1)],
help_text="Maximum content words per month (e.g., 100000 = 100K words)"
)
max_images_basic = models.IntegerField(
default=300,
validators=[MinValueValidator(0)],
help_text="Maximum basic AI images per month"
)
max_images_premium = models.IntegerField(
default=60,
validators=[MinValueValidator(0)],
help_text="Maximum premium AI images per month (DALL-E)"
)
max_image_prompts = models.IntegerField(
default=300,
validators=[MinValueValidator(0)],
help_text="Maximum image prompts per month"
)
# Billing & Credits (Phase 0: Credit-only system) # Billing & Credits (Phase 0: Credit-only system)
included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included") included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included")
extra_credit_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.01, help_text="Price per additional credit") extra_credit_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.01, help_text="Price per additional credit")

View File

@@ -13,6 +13,9 @@ class PlanSerializer(serializers.ModelSerializer):
'id', 'name', 'slug', 'price', 'billing_cycle', 'annual_discount_percent', 'id', 'name', 'slug', 'price', 'billing_cycle', 'annual_discount_percent',
'is_featured', 'features', 'is_active', 'is_featured', 'features', 'is_active',
'max_users', 'max_sites', 'max_industries', 'max_author_profiles', 'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
'max_keywords', 'max_clusters',
'max_content_ideas', 'max_content_words',
'max_images_basic', 'max_images_premium', 'max_image_prompts',
'included_credits', 'extra_credit_price', 'allow_credit_topup', 'included_credits', 'extra_credit_price', 'allow_credit_topup',
'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'auto_credit_topup_threshold', 'auto_credit_topup_amount',
'stripe_product_id', 'stripe_price_id', 'credits_per_month' 'stripe_product_id', 'stripe_price_id', 'credits_per_month'

View File

@@ -529,6 +529,14 @@ class SiteViewSet(AccountModelViewSet):
if user and user.is_authenticated: if user and user.is_authenticated:
account = getattr(user, 'account', None) account = getattr(user, 'account', None)
# Check hard limit for sites
from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError
try:
LimitService.check_hard_limit(account, 'sites', additional_count=1)
except HardLimitExceededError as e:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(str(e))
# Multiple sites can be active simultaneously - no constraint # Multiple sites can be active simultaneously - no constraint
site = serializer.save(account=account) site = serializer.save(account=account)

View File

@@ -189,6 +189,83 @@ class CreditCostConfig(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class PlanLimitUsage(AccountBaseModel):
"""
Track monthly usage of plan limits (ideas, words, images, prompts)
Resets at start of each billing period
"""
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 = models.CharField(
max_length=50,
choices=LIMIT_TYPE_CHOICES,
db_index=True,
help_text="Type of limit being tracked"
)
amount_used = models.IntegerField(
default=0,
validators=[MinValueValidator(0)],
help_text="Amount used in current period"
)
# Billing period tracking
period_start = models.DateField(
help_text="Start date of billing period"
)
period_end = models.DateField(
help_text="End date of billing period"
)
# Metadata
metadata = models.JSONField(
default=dict,
blank=True,
help_text="Additional tracking data (e.g., breakdown by site)"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_plan_limit_usage'
verbose_name = 'Plan Limit Usage'
verbose_name_plural = 'Plan Limit Usage Records'
unique_together = [['account', 'limit_type', 'period_start']]
ordering = ['-period_start', 'limit_type']
indexes = [
models.Index(fields=['account', 'limit_type']),
models.Index(fields=['account', 'period_start', 'period_end']),
models.Index(fields=['limit_type', 'period_start']),
]
def __str__(self):
account = getattr(self, 'account', None)
return f"{account.name if account else 'No Account'} - {self.get_limit_type_display()} - {self.amount_used} used"
def is_current_period(self):
"""Check if this record is for the current billing period"""
from django.utils import timezone
today = timezone.now().date()
return self.period_start <= today <= self.period_end
def remaining_allowance(self, plan_limit):
"""Calculate remaining allowance"""
return max(0, plan_limit - self.amount_used)
def percentage_used(self, plan_limit):
"""Calculate percentage of limit used"""
if plan_limit == 0:
return 0
return min(100, int((self.amount_used / plan_limit) * 100))
class Invoice(AccountBaseModel): class Invoice(AccountBaseModel):
""" """
Invoice for subscription or credit purchases Invoice for subscription or credit purchases

View File

@@ -0,0 +1,357 @@
"""
Limit Service for Plan Limit Enforcement
Manages hard limits (sites, users, keywords, clusters) and monthly limits (ideas, words, images, prompts)
"""
from django.db import transaction
from django.utils import timezone
from datetime import timedelta
import logging
from igny8_core.auth.models import Account
logger = logging.getLogger(__name__)
class LimitExceededError(Exception):
"""Base exception for limit exceeded errors"""
pass
class HardLimitExceededError(LimitExceededError):
"""Raised when a hard limit (sites, users, keywords, clusters) is exceeded"""
pass
class MonthlyLimitExceededError(LimitExceededError):
"""Raised when a monthly limit (ideas, words, images, prompts) is exceeded"""
pass
class LimitService:
"""Service for managing and enforcing plan limits"""
# Map limit types to model/field names
HARD_LIMIT_MAPPINGS = {
'sites': {
'model': 'igny8_core_auth.Site',
'plan_field': 'max_sites',
'display_name': 'Sites',
'filter_field': 'account',
},
'users': {
'model': 'igny8_core_auth.SiteUserAccess',
'plan_field': 'max_users',
'display_name': 'Team Users',
'filter_field': 'site__account',
},
'keywords': {
'model': 'planner.Keywords',
'plan_field': 'max_keywords',
'display_name': 'Keywords',
'filter_field': 'account',
},
'clusters': {
'model': 'planner.Clusters',
'plan_field': 'max_clusters',
'display_name': 'Clusters',
'filter_field': 'account',
},
}
MONTHLY_LIMIT_MAPPINGS = {
'content_ideas': {
'plan_field': 'max_content_ideas',
'usage_field': 'usage_content_ideas',
'display_name': 'Content Ideas',
},
'content_words': {
'plan_field': 'max_content_words',
'usage_field': 'usage_content_words',
'display_name': 'Content Words',
},
'images_basic': {
'plan_field': 'max_images_basic',
'usage_field': 'usage_images_basic',
'display_name': 'Basic Images',
},
'images_premium': {
'plan_field': 'max_images_premium',
'usage_field': 'usage_images_premium',
'display_name': 'Premium Images',
},
'image_prompts': {
'plan_field': 'max_image_prompts',
'usage_field': 'usage_image_prompts',
'display_name': 'Image Prompts',
},
}
@staticmethod
def check_hard_limit(account: Account, limit_type: str, additional_count: int = 1) -> bool:
"""
Check if adding items would exceed hard limit.
Args:
account: Account instance
limit_type: Type of limit
additional_count: Number of items to add
Returns:
bool: True if within limit
Raises:
HardLimitExceededError: If limit would be exceeded
"""
from django.apps import apps
if limit_type not in LimitService.HARD_LIMIT_MAPPINGS:
raise ValueError(f"Invalid hard limit type: {limit_type}")
config = LimitService.HARD_LIMIT_MAPPINGS[limit_type]
plan = account.plan
if not plan:
raise ValueError("Account has no plan")
plan_limit = getattr(plan, config['plan_field'])
model_path = config['model']
app_label, model_name = model_path.split('.')
Model = apps.get_model(app_label, model_name)
filter_field = config.get('filter_field', 'account')
filter_kwargs = {filter_field: account}
current_count = Model.objects.filter(**filter_kwargs).count()
new_count = current_count + additional_count
logger.info(f"Hard limit check: {limit_type} - Current: {current_count}, Requested: {additional_count}, Limit: {plan_limit}")
if new_count > plan_limit:
raise HardLimitExceededError(
f"{config['display_name']} limit exceeded. "
f"Current: {current_count}, Limit: {plan_limit}. "
f"Upgrade your plan to increase this limit."
)
return True
@staticmethod
def check_monthly_limit(account: Account, limit_type: str, amount: int = 1) -> bool:
"""
Check if operation would exceed monthly limit.
Args:
account: Account instance
limit_type: Type of limit
amount: Amount to use
Returns:
bool: True if within limit
Raises:
MonthlyLimitExceededError: If limit would be exceeded
"""
if limit_type not in LimitService.MONTHLY_LIMIT_MAPPINGS:
raise ValueError(f"Invalid monthly limit type: {limit_type}")
config = LimitService.MONTHLY_LIMIT_MAPPINGS[limit_type]
plan = account.plan
if not plan:
raise ValueError("Account has no plan")
plan_limit = getattr(plan, config['plan_field'])
current_usage = getattr(account, config['usage_field'], 0)
new_usage = current_usage + amount
logger.info(f"Monthly limit check: {limit_type} - Current: {current_usage}, Requested: {amount}, Limit: {plan_limit}")
if new_usage > plan_limit:
period_end = account.usage_period_end or timezone.now().date()
raise MonthlyLimitExceededError(
f"{config['display_name']} limit exceeded. "
f"Used: {current_usage}, Requested: {amount}, Limit: {plan_limit}. "
f"Resets on {period_end.strftime('%B %d, %Y')}. "
f"Upgrade your plan or wait for reset."
)
return True
@staticmethod
@transaction.atomic
def increment_usage(account: Account, limit_type: str, amount: int = 1, metadata: dict = None) -> int:
"""
Increment monthly usage after successful operation.
Args:
account: Account instance
limit_type: Type of limit
amount: Amount to increment
metadata: Optional metadata
Returns:
int: New usage amount
"""
if limit_type not in LimitService.MONTHLY_LIMIT_MAPPINGS:
raise ValueError(f"Invalid monthly limit type: {limit_type}")
config = LimitService.MONTHLY_LIMIT_MAPPINGS[limit_type]
usage_field = config['usage_field']
current_usage = getattr(account, usage_field, 0)
new_usage = current_usage + amount
setattr(account, usage_field, new_usage)
account.save(update_fields=[usage_field, 'updated_at'])
logger.info(f"Incremented {limit_type} usage by {amount}. New total: {new_usage}")
return new_usage
@staticmethod
def get_current_period(account: Account) -> tuple:
"""
Get current billing period start and end dates from account.
Args:
account: Account instance
Returns:
tuple: (period_start, period_end) as datetime objects
"""
if account.usage_period_start and account.usage_period_end:
return account.usage_period_start, account.usage_period_end
subscription = getattr(account, 'subscription', None)
if subscription and hasattr(subscription, 'current_period_start'):
period_start = subscription.current_period_start
period_end = subscription.current_period_end
else:
now = timezone.now()
period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if now.month == 12:
next_month = now.replace(year=now.year + 1, month=1, day=1)
else:
next_month = now.replace(month=now.month + 1, day=1)
period_end = next_month - timedelta(days=1)
period_end = period_end.replace(hour=23, minute=59, second=59)
account.usage_period_start = period_start
account.usage_period_end = period_end
account.save(update_fields=['usage_period_start', 'usage_period_end', 'updated_at'])
return period_start, period_end
@staticmethod
def get_usage_summary(account: Account) -> dict:
"""
Get comprehensive usage summary for all limits.
Args:
account: Account instance
Returns:
dict: Usage summary with hard and monthly limits
"""
from django.apps import apps
plan = account.plan
if not plan:
return {'error': 'No plan assigned to account'}
period_start, period_end = LimitService.get_current_period(account)
days_until_reset = (period_end.date() - timezone.now().date()).days if period_end else 0
summary = {
'account_id': account.id,
'account_name': account.name,
'plan_name': plan.name,
'period_start': period_start.isoformat() if period_start else None,
'period_end': period_end.isoformat() if period_end else None,
'days_until_reset': days_until_reset,
'hard_limits': {},
'monthly_limits': {},
}
for limit_type, config in LimitService.HARD_LIMIT_MAPPINGS.items():
model_path = config['model']
app_label, model_name = model_path.split('.')
Model = apps.get_model(app_label, model_name)
filter_field = config.get('filter_field', 'account')
filter_kwargs = {filter_field: account}
current_count = Model.objects.filter(**filter_kwargs).count()
plan_limit = getattr(plan, config['plan_field'])
summary['hard_limits'][limit_type] = {
'display_name': config['display_name'],
'current': current_count,
'limit': plan_limit,
'remaining': max(0, plan_limit - current_count),
'percentage_used': int((current_count / plan_limit) * 100) if plan_limit > 0 else 0,
}
for limit_type, config in LimitService.MONTHLY_LIMIT_MAPPINGS.items():
plan_limit = getattr(plan, config['plan_field'])
current_usage = getattr(account, config['usage_field'], 0)
summary['monthly_limits'][limit_type] = {
'display_name': config['display_name'],
'current': current_usage,
'limit': plan_limit,
'remaining': max(0, plan_limit - current_usage),
'percentage_used': int((current_usage / plan_limit) * 100) if plan_limit > 0 else 0,
}
return summary
@staticmethod
@transaction.atomic
def reset_monthly_limits(account: Account) -> dict:
"""
Reset all monthly limits for an account.
Args:
account: Account instance
Returns:
dict: Summary of reset operation
"""
account.usage_content_ideas = 0
account.usage_content_words = 0
account.usage_images_basic = 0
account.usage_images_premium = 0
account.usage_image_prompts = 0
old_period_end = account.usage_period_end
now = timezone.now()
new_period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if now.month == 12:
next_month = now.replace(year=now.year + 1, month=1, day=1)
else:
next_month = now.replace(month=now.month + 1, day=1)
new_period_end = next_month - timedelta(days=1)
new_period_end = new_period_end.replace(hour=23, minute=59, second=59)
account.usage_period_start = new_period_start
account.usage_period_end = new_period_end
account.save(update_fields=[
'usage_content_ideas', 'usage_content_words',
'usage_images_basic', 'usage_images_premium', 'usage_image_prompts',
'usage_period_start', 'usage_period_end', 'updated_at'
])
logger.info(f"Reset monthly limits for account {account.id}")
return {
'account_id': account.id,
'old_period_end': old_period_end.isoformat() if old_period_end else None,
'new_period_start': new_period_start.isoformat(),
'new_period_end': new_period_end.isoformat(),
'limits_reset': 5,
}

View File

@@ -7,6 +7,7 @@ from .views import (
PaymentViewSet, PaymentViewSet,
CreditPackageViewSet, CreditPackageViewSet,
AccountPaymentMethodViewSet, AccountPaymentMethodViewSet,
get_usage_summary,
) )
from igny8_core.modules.billing.views import ( from igny8_core.modules.billing.views import (
CreditBalanceViewSet, CreditBalanceViewSet,
@@ -29,4 +30,6 @@ router.register(r'payment-configs', BillingViewSet, basename='payment-configs')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
# User-facing usage summary endpoint for plan limits
path('usage-summary/', get_usage_summary, name='usage-summary'),
] ]

View File

@@ -866,3 +866,44 @@ class AccountPaymentMethodViewSet(AccountModelViewSet):
{'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results}, {'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results},
request=request request=request
) )
# ============================================================================
# USAGE SUMMARY (Plan Limits) - User-facing endpoint
# ============================================================================
from rest_framework.decorators import api_view, permission_classes
@api_view(['GET'])
@permission_classes([IsAuthenticatedAndActive, HasTenantAccess])
def get_usage_summary(request):
"""
Get comprehensive usage summary for current account.
Includes hard limits (sites, users, keywords, clusters) and monthly limits (ideas, words, images).
GET /api/v1/billing/usage-summary/
"""
try:
account = getattr(request, 'account', None)
if not account:
return error_response(
error='Account not found.',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
from igny8_core.business.billing.services.limit_service import LimitService
summary = LimitService.get_usage_summary(account)
return success_response(
data=summary,
message='Usage summary retrieved successfully.',
request=request
)
except Exception as e:
logger.error(f'Error getting usage summary: {str(e)}', exc_info=True)
return error_response(
error=f'Failed to retrieve usage summary: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)

View File

@@ -276,6 +276,55 @@ class Content(SoftDeletableModel, SiteSectorBaseModel):
def __str__(self): def __str__(self):
return self.title or f"Content {self.id}" return self.title or f"Content {self.id}"
def save(self, *args, **kwargs):
"""Override save to auto-calculate word_count from content_html"""
is_new = self.pk is None
old_word_count = 0
# Get old word count if updating
if not is_new and self.content_html:
try:
old_instance = Content.objects.get(pk=self.pk)
old_word_count = old_instance.word_count or 0
except Content.DoesNotExist:
pass
# Auto-calculate word count if content_html has changed
if self.content_html:
from igny8_core.utils.word_counter import calculate_word_count
calculated_count = calculate_word_count(self.content_html)
# Only update if different to avoid unnecessary saves
if self.word_count != calculated_count:
self.word_count = calculated_count
super().save(*args, **kwargs)
# Increment usage for new content or if word count increased
if self.content_html and self.word_count:
# Only count newly generated words
new_words = self.word_count - old_word_count if not is_new else self.word_count
if new_words > 0:
from igny8_core.business.billing.services.limit_service import LimitService
try:
# Get account from site
account = self.site.account if self.site else None
if account:
LimitService.increment_usage(
account=account,
limit_type='content_words',
amount=new_words,
metadata={
'content_id': self.id,
'content_title': self.title,
'site_id': self.site.id if self.site else None,
}
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error incrementing word usage for content {self.id}: {str(e)}")
class ContentTaxonomy(SiteSectorBaseModel): class ContentTaxonomy(SiteSectorBaseModel):

View File

@@ -30,12 +30,20 @@ class ContentGenerationService:
Raises: Raises:
InsufficientCreditsError: If account doesn't have enough credits InsufficientCreditsError: If account doesn't have enough credits
""" """
from igny8_core.business.billing.services.limit_service import LimitService, MonthlyLimitExceededError
# Get tasks # Get tasks
tasks = Tasks.objects.filter(id__in=task_ids, account=account) tasks = Tasks.objects.filter(id__in=task_ids, account=account)
# Calculate estimated credits needed based on word count # Calculate estimated credits needed based on word count
total_word_count = sum(task.word_count or 1000 for task in tasks) total_word_count = sum(task.word_count or 1000 for task in tasks)
# Check monthly word count limit
try:
LimitService.check_monthly_limit(account, 'content_words', amount=total_word_count)
except MonthlyLimitExceededError as e:
raise InsufficientCreditsError(str(e))
# Check credits # Check credits
try: try:
self.credit_service.check_credits(account, 'content_generation', total_word_count) self.credit_service.check_credits(account, 'content_generation', total_word_count)

View File

@@ -25,6 +25,15 @@ app.conf.beat_schedule = {
'task': 'igny8_core.modules.billing.tasks.replenish_monthly_credits', 'task': 'igny8_core.modules.billing.tasks.replenish_monthly_credits',
'schedule': crontab(hour=0, minute=0, day_of_month=1), # First day of month at midnight 'schedule': crontab(hour=0, minute=0, day_of_month=1), # First day of month at midnight
}, },
# Plan Limits Tasks
'reset-monthly-plan-limits': {
'task': 'reset_monthly_plan_limits',
'schedule': crontab(hour=0, minute=30), # Daily at 00:30 to check for period end
},
'check-approaching-limits': {
'task': 'check_approaching_limits',
'schedule': crontab(hour=9, minute=0), # Daily at 09:00 to warn users
},
# Automation Tasks # Automation Tasks
'check-scheduled-automations': { 'check-scheduled-automations': {
'task': 'automation.check_scheduled_automations', 'task': 'automation.check_scheduled_automations',

View File

@@ -1,53 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-09 13:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0013_add_webhook_config'),
]
operations = [
migrations.RemoveIndex(
model_name='payment',
name='payment_account_status_created_idx',
),
migrations.RemoveField(
model_name='invoice',
name='billing_email',
),
migrations.RemoveField(
model_name='invoice',
name='billing_period_end',
),
migrations.RemoveField(
model_name='invoice',
name='billing_period_start',
),
migrations.RemoveField(
model_name='payment',
name='transaction_reference',
),
migrations.AlterField(
model_name='accountpaymentmethod',
name='type',
field=models.CharField(choices=[('stripe', 'Stripe (Credit/Debit Card)'), ('paypal', 'PayPal'), ('bank_transfer', 'Bank Transfer (Manual)'), ('local_wallet', 'Local Wallet (Manual)'), ('manual', 'Manual Payment')], db_index=True, max_length=50),
),
migrations.AlterField(
model_name='credittransaction',
name='reference_id',
field=models.CharField(blank=True, help_text='DEPRECATED: Use payment FK. Legacy reference (e.g., payment id, invoice id)', max_length=255),
),
migrations.AlterField(
model_name='paymentmethodconfig',
name='payment_method',
field=models.CharField(choices=[('stripe', 'Stripe (Credit/Debit Card)'), ('paypal', 'PayPal'), ('bank_transfer', 'Bank Transfer (Manual)'), ('local_wallet', 'Local Wallet (Manual)'), ('manual', 'Manual Payment')], max_length=50),
),
migrations.AlterField(
model_name='paymentmethodconfig',
name='webhook_url',
field=models.URLField(blank=True, help_text='Webhook URL for payment gateway callbacks'),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.8 on 2025-12-12 11:26
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0013_add_webhook_config'),
('igny8_core_auth', '0013_plan_max_clusters_plan_max_content_ideas_and_more'),
]
operations = [
migrations.CreateModel(
name='PlanLimitUsage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('limit_type', models.CharField(choices=[('content_ideas', 'Content Ideas'), ('content_words', 'Content Words'), ('images_basic', 'Basic Images'), ('images_premium', 'Premium Images'), ('image_prompts', 'Image Prompts')], db_index=True, help_text='Type of limit being tracked', max_length=50)),
('amount_used', models.IntegerField(default=0, help_text='Amount used in current period', validators=[django.core.validators.MinValueValidator(0)])),
('period_start', models.DateField(help_text='Start date of billing period')),
('period_end', models.DateField(help_text='End date of billing period')),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional tracking data (e.g., breakdown by site)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
],
options={
'verbose_name': 'Plan Limit Usage',
'verbose_name_plural': 'Plan Limit Usage Records',
'db_table': 'igny8_plan_limit_usage',
'ordering': ['-period_start', 'limit_type'],
'indexes': [models.Index(fields=['account', 'limit_type'], name='igny8_plan__tenant__993f7b_idx'), models.Index(fields=['account', 'period_start', 'period_end'], name='igny8_plan__tenant__aba01f_idx'), models.Index(fields=['limit_type', 'period_start'], name='igny8_plan__limit_t_d0f5ef_idx')],
'unique_together': {('account', 'limit_type', 'period_start')},
},
),
]

View File

@@ -0,0 +1,168 @@
"""
Plan Limits Reset Task
Scheduled task to reset monthly plan limits at billing period end
"""
from celery import shared_task
from django.utils import timezone
from datetime import timedelta
import logging
from igny8_core.auth.models import Account
from igny8_core.business.billing.services.limit_service import LimitService
logger = logging.getLogger(__name__)
@shared_task(name='reset_monthly_plan_limits')
def reset_monthly_plan_limits():
"""
Reset monthly plan limits for accounts whose billing period has ended.
This task should run daily (recommended: midnight UTC).
It finds all accounts where the billing period has ended and resets their monthly usage.
Monthly limits that get reset:
- content_ideas
- content_words
- images_basic
- images_premium
- image_prompts
Hard limits (sites, users, keywords, clusters) are NOT reset.
"""
logger.info("Starting monthly plan limits reset task")
today = timezone.now().date()
reset_count = 0
error_count = 0
# Find all active accounts with subscriptions
accounts = Account.objects.filter(
status='active',
subscription__isnull=False
).select_related('subscription', 'plan')
logger.info(f"Found {accounts.count()} active accounts with subscriptions")
for account in accounts:
try:
subscription = account.subscription
# Check if billing period has ended
if subscription.current_period_end and subscription.current_period_end.date() <= today:
logger.info(f"Resetting limits for account {account.id} ({account.name}) - "
f"period ended {subscription.current_period_end.date()}")
# Reset monthly limits
result = LimitService.reset_monthly_limits(account)
# Update subscription period
from dateutil.relativedelta import relativedelta
# Calculate new period based on billing cycle
plan = account.plan
if plan.billing_cycle == 'monthly':
new_period_start = subscription.current_period_end + timedelta(days=1)
new_period_end = new_period_start + relativedelta(months=1) - timedelta(days=1)
elif plan.billing_cycle == 'annual':
new_period_start = subscription.current_period_end + timedelta(days=1)
new_period_end = new_period_start + relativedelta(years=1) - timedelta(days=1)
else:
# Default to monthly
new_period_start = subscription.current_period_end + timedelta(days=1)
new_period_end = new_period_start + relativedelta(months=1) - timedelta(days=1)
# Update subscription
subscription.current_period_start = new_period_start
subscription.current_period_end = new_period_end
subscription.save(update_fields=['current_period_start', 'current_period_end'])
reset_count += 1
logger.info(f"Reset complete for account {account.id}: "
f"New period {new_period_start} to {new_period_end}")
except Exception as e:
error_count += 1
logger.error(f"Error resetting limits for account {account.id}: {str(e)}", exc_info=True)
logger.info(f"Monthly plan limits reset task complete: "
f"{reset_count} accounts reset, {error_count} errors")
return {
'reset_count': reset_count,
'error_count': error_count,
'total_accounts': accounts.count(),
}
@shared_task(name='check_approaching_limits')
def check_approaching_limits(threshold_percentage=80):
"""
Check for accounts approaching their plan limits and send notifications.
Args:
threshold_percentage: Percentage at which to trigger warning (default 80%)
This task should run daily.
It checks both hard and monthly limits and sends warnings when usage exceeds threshold.
"""
logger.info(f"Starting limit warning check task (threshold: {threshold_percentage}%)")
warning_count = 0
# Find all active accounts
accounts = Account.objects.filter(status='active').select_related('plan')
for account in accounts:
try:
if not account.plan:
continue
# Get usage summary
summary = LimitService.get_usage_summary(account)
warnings = []
# Check hard limits
for limit_type, data in summary.get('hard_limits', {}).items():
if data['percentage_used'] >= threshold_percentage:
warnings.append({
'type': 'hard',
'limit_type': limit_type,
'display_name': data['display_name'],
'current': data['current'],
'limit': data['limit'],
'percentage': data['percentage_used'],
})
# Check monthly limits
for limit_type, data in summary.get('monthly_limits', {}).items():
if data['percentage_used'] >= threshold_percentage:
warnings.append({
'type': 'monthly',
'limit_type': limit_type,
'display_name': data['display_name'],
'current': data['current'],
'limit': data['limit'],
'percentage': data['percentage_used'],
'resets_in_days': summary.get('days_until_reset', 0),
})
if warnings:
warning_count += 1
logger.info(f"Account {account.id} ({account.name}) has {len(warnings)} limit warnings")
# TODO: Send email notification
# from igny8_core.business.billing.services.email_service import send_limit_warning_email
# send_limit_warning_email(account, warnings)
except Exception as e:
logger.error(f"Error checking limits for account {account.id}: {str(e)}", exc_info=True)
logger.info(f"Limit warning check complete: {warning_count} accounts with warnings")
return {
'warning_count': warning_count,
'total_accounts': accounts.count(),
}

View File

@@ -0,0 +1,137 @@
"""
Word Counter Utility
Standardized word counting from HTML content
Single source of truth for Content.word_count calculation
"""
import re
import logging
logger = logging.getLogger(__name__)
# Try to import BeautifulSoup, fallback to regex if not available
try:
from bs4 import BeautifulSoup
HAS_BEAUTIFULSOUP = True
except ImportError:
HAS_BEAUTIFULSOUP = False
logger.warning("BeautifulSoup4 not available, using regex fallback for word counting")
def calculate_word_count(html_content: str) -> int:
"""
Calculate word count from HTML content by stripping tags and counting words.
This is the SINGLE SOURCE OF TRUTH for word counting in IGNY8.
All limit tracking and billing should use Content.word_count which is calculated using this function.
Args:
html_content: HTML string to count words from
Returns:
int: Number of words in the content
Examples:
>>> calculate_word_count("<p>Hello world</p>")
2
>>> calculate_word_count("<h1>Title</h1><p>This is a paragraph.</p>")
5
>>> calculate_word_count("")
0
>>> calculate_word_count(None)
0
"""
# Handle None or empty content
if not html_content or not isinstance(html_content, str):
return 0
html_content = html_content.strip()
if not html_content:
return 0
try:
# Use BeautifulSoup if available (more accurate)
if HAS_BEAUTIFULSOUP:
soup = BeautifulSoup(html_content, 'html.parser')
# Get text, removing all HTML tags
text = soup.get_text(separator=' ', strip=True)
else:
# Fallback to regex (less accurate but functional)
# Remove all HTML tags
text = re.sub(r'<[^>]+>', ' ', html_content)
# Remove extra whitespace
text = ' '.join(text.split())
# Count words (split on whitespace)
if not text:
return 0
words = text.split()
word_count = len(words)
logger.debug(f"Calculated word count: {word_count} from {len(html_content)} chars of HTML")
return word_count
except Exception as e:
logger.error(f"Error calculating word count: {e}", exc_info=True)
# Return 0 on error rather than failing
return 0
def format_word_count(word_count: int) -> str:
"""
Format word count for display (e.g., 1000 -> "1K", 100000 -> "100K")
Args:
word_count: Number of words
Returns:
str: Formatted word count
Examples:
>>> format_word_count(500)
'500'
>>> format_word_count(1500)
'1.5K'
>>> format_word_count(100000)
'100K'
>>> format_word_count(1500000)
'1.5M'
"""
if word_count >= 1000000:
return f"{word_count / 1000000:.1f}M"
elif word_count >= 1000:
# Show .5K if not a whole number, otherwise just show K
result = word_count / 1000
if result == int(result):
return f"{int(result)}K"
return f"{result:.1f}K"
return str(word_count)
def validate_word_count_limit(current_words: int, requested_words: int, limit: int) -> dict:
"""
Validate if requested word generation would exceed limit.
Args:
current_words: Current word count used in period
requested_words: Words being requested
limit: Maximum words allowed
Returns:
dict with:
- allowed (bool): Whether operation is allowed
- remaining (int): Remaining words available
- would_exceed_by (int): How many words over limit if not allowed
"""
remaining = max(0, limit - current_words)
allowed = current_words + requested_words <= limit
would_exceed_by = max(0, (current_words + requested_words) - limit)
return {
'allowed': allowed,
'remaining': remaining,
'would_exceed_by': would_exceed_by,
'current': current_words,
'requested': requested_words,
'limit': limit,
}

View File

@@ -101,28 +101,6 @@ services:
- "com.docker.compose.project=igny8-app" - "com.docker.compose.project=igny8-app"
- "com.docker.compose.service=igny8_marketing_dev" - "com.docker.compose.service=igny8_marketing_dev"
igny8_sites:
# Sites Renderer - serves deployed public sites
# Build separately: docker build -t igny8-sites-dev:latest -f Dockerfile.dev .
# Accessible at http://31.97.144.105:8024 (direct) or via Caddy routing
image: igny8-sites-dev:latest
container_name: igny8_sites
restart: always
ports:
- "0.0.0.0:8024:5176" # Sites renderer port (internal: 5176, external: 8024)
environment:
VITE_API_URL: "https://api.igny8.com/api"
SITES_DATA_PATH: "/sites"
volumes:
- /data/app/igny8/sites:/app:rw
- /data/app/sites-data:/sites:ro # Read-only access to deployed sites
- /data/app/igny8/frontend:/frontend:ro # Read-only access to shared components
networks: [igny8_net]
labels:
- "com.docker.compose.project=igny8-app"
- "com.docker.compose.service=igny8_sites"
igny8_celery_worker: igny8_celery_worker:
image: igny8-backend:latest image: igny8-backend:latest
container_name: igny8_celery_worker container_name: igny8_celery_worker

677
docs/PLAN-LIMITS.md Normal file
View File

@@ -0,0 +1,677 @@
# 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('<p>Hello <strong>world</strong>!</p>')
# 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 <token>" \
/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<UsageSummary> => {
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
<Card>
<CardHeader>
<h3>Usage This Month</h3>
<span>{usage.days_until_reset} days until reset</span>
</CardHeader>
<CardBody>
{Object.entries(usage.monthly_limits).map(([key, data]) => (
<div key={key}>
<div>{data.display_name}</div>
<ProgressBar
value={data.percentage_used}
variant={data.percentage_used >= 80 ? 'warning' : 'primary'}
/>
<span>{data.current.toLocaleString()} / {data.limit.toLocaleString()}</span>
</div>
))}
</CardBody>
</Card>
```
#### Limit Warning Alert
```tsx
{usage.monthly_limits.content_words.percentage_used >= 80 && (
<Alert variant="warning">
You've used {usage.monthly_limits.content_words.percentage_used}% of your
monthly word limit. Resets in {usage.days_until_reset} days.
<Link to="/billing/plans">Upgrade Plan</Link>
</Alert>
)}
```
---
## 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**

View File

@@ -0,0 +1,245 @@
/**
* Usage Limits Panel Component
* Displays hard and monthly plan limits with usage tracking
*/
import { useState, useEffect } from 'react';
import { Card } from '../ui/card';
import { AlertCircle, TrendingUp, Users, Globe, Tag, FileText, Image, Zap } from 'lucide-react';
import Badge from '../ui/badge/Badge';
import { getUsageSummary, type UsageSummary, type LimitUsage } from '../../services/billing.api';
import { useToast } from '../ui/toast/ToastContainer';
interface LimitCardProps {
title: string;
icon: React.ReactNode;
usage: LimitUsage;
type: 'hard' | 'monthly';
daysUntilReset?: number;
}
function LimitCard({ title, icon, usage, type, daysUntilReset }: LimitCardProps) {
const percentage = usage.percentage_used;
const isWarning = percentage >= 80;
const isDanger = percentage >= 95;
let barColor = 'bg-blue-500';
let badgeVariant: 'default' | 'warning' | 'danger' = 'default';
if (isDanger) {
barColor = 'bg-red-500';
badgeVariant = 'danger';
} else if (isWarning) {
barColor = 'bg-yellow-500';
badgeVariant = 'warning';
}
return (
<Card className="p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-blue-600 dark:text-blue-400">
{icon}
</div>
<div>
<h3 className="font-medium text-gray-900 dark:text-white">{title}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{type === 'monthly' && daysUntilReset !== undefined
? `Resets in ${daysUntilReset} days`
: 'Lifetime limit'}
</p>
</div>
</div>
<Badge variant={badgeVariant}>{percentage}%</Badge>
</div>
{/* Progress Bar */}
<div className="mb-3">
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full ${barColor} transition-all duration-300`}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</div>
{/* Usage Stats */}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
{usage.current.toLocaleString()} / {usage.limit.toLocaleString()}
</span>
<span className="font-medium text-gray-900 dark:text-white">
{usage.remaining.toLocaleString()} remaining
</span>
</div>
{/* Warning Message */}
{isWarning && (
<div className={`mt-3 flex items-start gap-2 text-xs ${
isDanger ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
}`}>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>
{isDanger
? 'Limit almost reached! Consider upgrading your plan.'
: 'Approaching limit. Monitor your usage carefully.'}
</span>
</div>
)}
</Card>
);
}
export default function UsageLimitsPanel() {
const toast = useToast();
const [summary, setSummary] = useState<UsageSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>('');
useEffect(() => {
loadUsageSummary();
}, []);
const loadUsageSummary = async () => {
try {
setLoading(true);
setError('');
const data = await getUsageSummary();
setSummary(data);
} catch (err: any) {
const message = err?.message || 'Failed to load usage summary';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="space-y-4">
<div className="animate-pulse">
<div className="h-32 bg-gray-200 dark:bg-gray-700 rounded-lg mb-4"></div>
<div className="h-32 bg-gray-200 dark:bg-gray-700 rounded-lg mb-4"></div>
<div className="h-32 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
</div>
</div>
);
}
if (error || !summary) {
return (
<Card className="p-6 text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Failed to Load Usage Data
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">{error || 'Unknown error'}</p>
<button
onClick={loadUsageSummary}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retry
</button>
</Card>
);
}
const hardLimitIcons = {
sites: <Globe className="w-5 h-5" />,
users: <Users className="w-5 h-5" />,
keywords: <Tag className="w-5 h-5" />,
clusters: <TrendingUp className="w-5 h-5" />,
};
const monthlyLimitIcons = {
content_ideas: <FileText className="w-5 h-5" />,
content_words: <FileText className="w-5 h-5" />,
images_basic: <Image className="w-5 h-5" />,
images_premium: <Zap className="w-5 h-5" />,
image_prompts: <Image className="w-5 h-5" />,
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Plan Limits & Usage</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Current Plan: <span className="font-medium">{summary.plan_name}</span>
</p>
</div>
{summary.days_until_reset !== undefined && (
<Badge variant="default">
Resets in {summary.days_until_reset} days
</Badge>
)}
</div>
{/* Hard Limits Section */}
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Account Limits
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Object.entries(summary.hard_limits).map(([key, usage]) => (
<LimitCard
key={key}
title={usage.display_name}
icon={hardLimitIcons[key as keyof typeof hardLimitIcons]}
usage={usage}
type="hard"
/>
))}
</div>
</div>
{/* Monthly Limits Section */}
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Monthly Usage Limits
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(summary.monthly_limits).map(([key, usage]) => (
<LimitCard
key={key}
title={usage.display_name}
icon={monthlyLimitIcons[key as keyof typeof monthlyLimitIcons]}
usage={usage}
type="monthly"
daysUntilReset={summary.days_until_reset}
/>
))}
</div>
</div>
{/* Upgrade CTA if approaching limits */}
{(Object.values(summary.hard_limits).some(u => u.percentage_used >= 80) ||
Object.values(summary.monthly_limits).some(u => u.percentage_used >= 80)) && (
<Card className="p-6 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-blue-200 dark:border-blue-700">
<div className="flex items-start gap-4">
<div className="p-3 bg-blue-600 rounded-lg text-white">
<TrendingUp className="w-6 h-6" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Approaching Your Limits
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
You're using a significant portion of your plan's resources. Upgrade to get higher limits
and avoid interruptions.
</p>
<a
href="/account/plans-and-billing"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
View Plans & Upgrade
</a>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -18,6 +18,17 @@ export interface PricingPlan {
features: string[]; features: string[];
buttonText: string; buttonText: string;
highlighted?: boolean; highlighted?: boolean;
disabled?: boolean;
// Plan limits
max_sites?: number;
max_users?: number;
max_keywords?: number;
max_clusters?: number;
max_content_ideas?: number;
max_content_words?: number;
max_images_basic?: number;
max_images_premium?: number;
included_credits?: number;
} }
interface PricingTableProps { interface PricingTableProps {
@@ -124,12 +135,68 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
<span className="text-sm text-gray-700 dark:text-gray-300">{feature}</span> <span className="text-sm text-gray-700 dark:text-gray-300">{feature}</span>
</li> </li>
))} ))}
{/* Plan Limits Section */}
{(plan.max_sites || plan.max_content_words || plan.included_credits) && (
<div className="pt-3 mt-3 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2">LIMITS</div>
{plan.max_sites && (
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<span className="text-xs text-gray-600 dark:text-gray-400">
{plan.max_sites === 99999 ? 'Unlimited' : plan.max_sites} Sites
</span>
</li>
)}
{plan.max_users && (
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<span className="text-xs text-gray-600 dark:text-gray-400">
{plan.max_users === 99999 ? 'Unlimited' : plan.max_users} Team Members
</span>
</li>
)}
{plan.max_content_words && (
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<span className="text-xs text-gray-600 dark:text-gray-400">
{(plan.max_content_words / 1000).toLocaleString()}K Words/month
</span>
</li>
)}
{plan.max_content_ideas && (
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<span className="text-xs text-gray-600 dark:text-gray-400">
{plan.max_content_ideas} Ideas/month
</span>
</li>
)}
{plan.max_images_basic && (
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<span className="text-xs text-gray-600 dark:text-gray-400">
{plan.max_images_basic} Images/month
</span>
</li>
)}
{plan.included_credits && (
<li className="flex items-start gap-2">
<Check className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<span className="text-xs text-gray-600 dark:text-gray-400">
{plan.included_credits.toLocaleString()} Credits/month
</span>
</li>
)}
</div>
)}
</ul> </ul>
<Button <Button
variant={plan.highlighted ? 'primary' : 'outline'} variant={plan.highlighted ? 'primary' : 'outline'}
className="w-full" className="w-full"
onClick={() => onPlanSelect?.(plan)} onClick={() => onPlanSelect?.(plan)}
disabled={plan.disabled}
> >
{plan.buttonText} {plan.buttonText}
</Button> </Button>

View File

@@ -2,6 +2,7 @@
layer(base); layer(base);
@import "./styles/tokens.css"; @import "./styles/tokens.css";
@import "./styles/account-colors.css";
@import "tailwindcss"; @import "tailwindcss";
@keyframes slide-in-right { @keyframes slide-in-right {

View File

@@ -502,6 +502,16 @@ export default function PlansAndBillingPage() {
buttonText: plan.id === currentPlanId ? 'Current Plan' : 'Select Plan', buttonText: plan.id === currentPlanId ? 'Current Plan' : 'Select Plan',
highlighted: plan.is_featured || false, highlighted: plan.is_featured || false,
disabled: plan.id === currentPlanId || planLoadingId === plan.id, disabled: plan.id === currentPlanId || planLoadingId === plan.id,
// Plan limits
max_sites: plan.max_sites,
max_users: plan.max_users,
max_keywords: plan.max_keywords,
max_clusters: plan.max_clusters,
max_content_ideas: plan.max_content_ideas,
max_content_words: plan.max_content_words,
max_images_basic: plan.max_images_basic,
max_images_premium: plan.max_images_premium,
included_credits: plan.included_credits,
}; };
})} })}
showToggle={true} showToggle={true}

View File

@@ -1,10 +1,10 @@
/** /**
* Usage & Analytics Page * Usage & Analytics Page
* Tabs: Credit Usage, API Usage, Cost Breakdown * Tabs: Plan Limits, Credit Usage, API Usage, Cost Breakdown
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { TrendingUp, Activity, DollarSign } from 'lucide-react'; import { TrendingUp, Activity, DollarSign, BarChart3 } from 'lucide-react';
import PageMeta from '../../components/common/PageMeta'; import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { getUsageAnalytics, UsageAnalytics } from '../../services/billing.api'; import { getUsageAnalytics, UsageAnalytics } from '../../services/billing.api';
@@ -12,13 +12,14 @@ import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge'; import Badge from '../../components/ui/badge/Badge';
import BillingUsagePanel from '../../components/billing/BillingUsagePanel'; import BillingUsagePanel from '../../components/billing/BillingUsagePanel';
import BillingBalancePanel from '../../components/billing/BillingBalancePanel'; import BillingBalancePanel from '../../components/billing/BillingBalancePanel';
import UsageLimitsPanel from '../../components/billing/UsageLimitsPanel';
import Button from '../../components/ui/button/Button'; import Button from '../../components/ui/button/Button';
type TabType = 'credits' | 'api' | 'costs'; type TabType = 'limits' | 'credits' | 'balance' | 'api' | 'costs';
export default function UsageAnalyticsPage() { export default function UsageAnalyticsPage() {
const toast = useToast(); const toast = useToast();
const [activeTab, setActiveTab] = useState<TabType>('credits'); const [activeTab, setActiveTab] = useState<TabType>('limits');
const [analytics, setAnalytics] = useState<UsageAnalytics | null>(null); const [analytics, setAnalytics] = useState<UsageAnalytics | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState(30); const [period, setPeriod] = useState(30);
@@ -51,6 +52,7 @@ export default function UsageAnalyticsPage() {
} }
const tabs = [ const tabs = [
{ id: 'limits' as TabType, label: 'Plan Limits', icon: <BarChart3 className="w-4 h-4" /> },
{ id: 'credits' as TabType, label: 'Credit Usage', icon: <TrendingUp className="w-4 h-4" /> }, { id: 'credits' as TabType, label: 'Credit Usage', icon: <TrendingUp className="w-4 h-4" /> },
{ id: 'balance' as TabType, label: 'Credit Balance', icon: <DollarSign className="w-4 h-4" /> }, { id: 'balance' as TabType, label: 'Credit Balance', icon: <DollarSign className="w-4 h-4" /> },
{ id: 'api' as TabType, label: 'API Usage', icon: <Activity className="w-4 h-4" /> }, { id: 'api' as TabType, label: 'API Usage', icon: <Activity className="w-4 h-4" /> },
@@ -64,7 +66,7 @@ export default function UsageAnalyticsPage() {
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Usage & Analytics</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Usage & Analytics</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1"> <p className="text-gray-600 dark:text-gray-400 mt-1">
Monitor credit usage, API calls, and cost breakdown Monitor plan limits, credit usage, API calls, and cost breakdown
</p> </p>
</div> </div>
@@ -112,6 +114,11 @@ export default function UsageAnalyticsPage() {
{/* Tab Content */} {/* Tab Content */}
<div className="mt-6"> <div className="mt-6">
{/* Plan Limits Tab */}
{activeTab === 'limits' && (
<UsageLimitsPanel />
)}
{/* Credit Usage Tab */} {/* Credit Usage Tab */}
{activeTab === 'credits' && ( {activeTab === 'credits' && (
<div className="space-y-6"> <div className="space-y-6">

View File

@@ -865,6 +865,48 @@ export interface Plan {
features?: string[]; features?: string[];
limits?: Record<string, any>; limits?: Record<string, any>;
display_order?: number; display_order?: number;
// 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;
included_credits?: number;
}
export interface LimitUsage {
display_name: string;
current: number;
limit: number;
remaining: number;
percentage_used: number;
}
export interface UsageSummary {
account_id: number;
account_name: string;
plan_name: string;
period_start: string;
period_end: string;
days_until_reset: number;
hard_limits: {
sites?: LimitUsage;
users?: LimitUsage;
keywords?: LimitUsage;
clusters?: LimitUsage;
};
monthly_limits: {
content_ideas?: LimitUsage;
content_words?: LimitUsage;
images_basic?: LimitUsage;
images_premium?: LimitUsage;
image_prompts?: LimitUsage;
};
} }
export interface Subscription { export interface Subscription {
@@ -942,3 +984,11 @@ export async function cancelSubscription(subscriptionId: number): Promise<{ mess
method: 'POST', method: 'POST',
}); });
} }
// ============================================================================
// USAGE SUMMARY (PLAN LIMITS)
// ============================================================================
export async function getUsageSummary(): Promise<UsageSummary> {
return fetchAPI('/v1/billing/usage-summary/');
}

View File

@@ -0,0 +1,435 @@
/* ===================================================================
IGNY8 ACCOUNT SECTION - CUSTOM COLOR SCHEMES
===================================================================
Brand-specific styling for account, billing, and usage pages
Follows IGNY8 design system with enhanced gradients and visual hierarchy
=================================================================== */
/* Account Page Container */
.account-page {
background: linear-gradient(135deg, #f8fafc 0%, #e0e7ff 100%);
min-height: 100vh;
}
.dark .account-page {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
}
/* === IGNY8 BRAND GRADIENTS === */
.igny8-gradient-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
}
.igny8-gradient-success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.igny8-gradient-warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.igny8-gradient-danger {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
.igny8-gradient-purple {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
}
.igny8-gradient-teal {
background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
}
/* === CARD VARIANTS === */
.igny8-card-premium {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(37, 99, 235, 0.05) 100%);
border: 1px solid rgba(59, 130, 246, 0.2);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.1), 0 2px 4px -1px rgba(59, 130, 246, 0.06);
}
.dark .igny8-card-premium {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(37, 99, 235, 0.1) 100%);
border: 1px solid rgba(59, 130, 246, 0.3);
}
.igny8-card-success {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(5, 150, 105, 0.05) 100%);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.dark .igny8-card-success {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.igny8-card-warning {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.05) 0%, rgba(217, 119, 6, 0.05) 100%);
border: 1px solid rgba(245, 158, 11, 0.2);
}
.dark .igny8-card-warning {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(217, 119, 6, 0.1) 100%);
border: 1px solid rgba(245, 158, 11, 0.3);
}
/* === USAGE METRICS === */
.usage-metric-card {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.usage-metric-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
}
.usage-metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.usage-metric-card.warning::before {
background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%);
}
.usage-metric-card.danger::before {
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%);
}
.usage-metric-card.success::before {
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
}
/* === PROGRESS BARS === */
.igny8-progress-bar {
height: 8px;
background: #e5e7eb;
border-radius: 9999px;
overflow: hidden;
position: relative;
}
.dark .igny8-progress-bar {
background: #374151;
}
.igny8-progress-fill {
height: 100%;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.igny8-progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0) 100%
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.igny8-progress-fill.primary {
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
}
.igny8-progress-fill.warning {
background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%);
}
.igny8-progress-fill.danger {
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%);
}
.igny8-progress-fill.success {
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
}
/* === STAT NUMBERS === */
.igny8-stat-number {
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -0.025em;
}
.igny8-stat-number.primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.igny8-stat-number.success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.igny8-stat-number.warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.igny8-stat-number.danger {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* === BADGES === */
.igny8-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.025em;
text-transform: uppercase;
}
.igny8-badge.primary {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(37, 99, 235, 0.1) 100%);
color: #2563eb;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.dark .igny8-badge.primary {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(37, 99, 235, 0.2) 100%);
color: #60a5fa;
}
.igny8-badge.success {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%);
color: #059669;
border: 1px solid rgba(16, 185, 129, 0.2);
}
.dark .igny8-badge.success {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(5, 150, 105, 0.2) 100%);
color: #34d399;
}
.igny8-badge.warning {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(217, 119, 6, 0.1) 100%);
color: #d97706;
border: 1px solid rgba(245, 158, 11, 0.2);
}
.dark .igny8-badge.warning {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(217, 119, 6, 0.2) 100%);
color: #fbbf24;
}
.igny8-badge.danger {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%);
color: #dc2626;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.dark .igny8-badge.danger {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(220, 38, 38, 0.2) 100%);
color: #f87171;
}
/* === PLAN CARDS === */
.igny8-plan-card {
position: relative;
border-radius: 1rem;
overflow: hidden;
transition: all 0.3s ease;
}
.igny8-plan-card.featured {
border: 2px solid #3b82f6;
box-shadow: 0 20px 25px -5px rgba(59, 130, 246, 0.1), 0 10px 10px -5px rgba(59, 130, 246, 0.04);
}
.igny8-plan-card.featured::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 50%, #1d4ed8 100%);
}
.igny8-plan-card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* === LIMIT DISPLAY === */
.igny8-limit-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 0.5rem;
background: rgba(59, 130, 246, 0.05);
border: 1px solid rgba(59, 130, 246, 0.1);
transition: all 0.2s ease;
}
.dark .igny8-limit-item {
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.2);
}
.igny8-limit-item:hover {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.2);
transform: translateX(2px);
}
.dark .igny8-limit-item:hover {
background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.3);
}
/* === BILLING HISTORY TABLE === */
.igny8-billing-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.igny8-billing-table thead th {
background: linear-gradient(180deg, #f9fafb 0%, #f3f4f6 100%);
color: #6b7280;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
}
.dark .igny8-billing-table thead th {
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
color: #9ca3af;
border-bottom: 1px solid #374151;
}
.igny8-billing-table tbody tr {
border-bottom: 1px solid #e5e7eb;
transition: background-color 0.2s ease;
}
.dark .igny8-billing-table tbody tr {
border-bottom: 1px solid #374151;
}
.igny8-billing-table tbody tr:hover {
background: rgba(59, 130, 246, 0.02);
}
.dark .igny8-billing-table tbody tr:hover {
background: rgba(59, 130, 246, 0.05);
}
.igny8-billing-table tbody td {
padding: 1rem;
color: #374151;
}
.dark .igny8-billing-table tbody td {
color: #d1d5db;
}
/* === UPGRADE CTA === */
.igny8-upgrade-cta {
position: relative;
overflow: hidden;
border-radius: 1rem;
padding: 2rem;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
}
.igny8-upgrade-cta::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
opacity: 0.1;
}
.igny8-upgrade-cta-content {
position: relative;
z-index: 1;
}
/* === ANIMATIONS === */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.igny8-fade-in-up {
animation: fadeInUp 0.6s ease-out;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.igny8-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* === RESPONSIVE UTILITIES === */
@media (max-width: 640px) {
.igny8-stat-number {
font-size: 1.5rem;
}
.usage-metric-card {
padding: 1rem;
}
.igny8-plan-card {
margin-bottom: 1rem;
}
}