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:
380
.cursorrules
Normal file
380
.cursorrules
Normal 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.
|
||||
224
CHANGELOG.md
224
CHANGELOG.md
@@ -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
|
||||
This document tracks all changes made to the multi-tenancy system during the current staging session and the last 2 commits (4d13a570 and 72d0b6b0).
|
||||
|
||||
465
PLAN-LIMITS-IMPLEMENTATION.md
Normal file
465
PLAN-LIMITS-IMPLEMENTATION.md
Normal 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
|
||||
@@ -111,17 +111,26 @@ class AccountAdminForm(forms.ModelForm):
|
||||
@admin.register(Plan)
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
"""Plan admin - Global, no account filtering needed"""
|
||||
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'included_credits', 'is_active']
|
||||
list_filter = ['is_active', 'billing_cycle']
|
||||
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', 'is_internal']
|
||||
search_fields = ['name', 'slug']
|
||||
readonly_fields = ['created_at']
|
||||
|
||||
fieldsets = (
|
||||
('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', {
|
||||
'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', {
|
||||
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
|
||||
|
||||
@@ -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)]),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -106,6 +106,15 @@ class Account(SoftDeletableModel):
|
||||
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")
|
||||
|
||||
# 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)
|
||||
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_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)
|
||||
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")
|
||||
|
||||
@@ -13,6 +13,9 @@ class PlanSerializer(serializers.ModelSerializer):
|
||||
'id', 'name', 'slug', 'price', 'billing_cycle', 'annual_discount_percent',
|
||||
'is_featured', 'features', 'is_active',
|
||||
'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',
|
||||
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
|
||||
'stripe_product_id', 'stripe_price_id', 'credits_per_month'
|
||||
|
||||
@@ -529,6 +529,14 @@ class SiteViewSet(AccountModelViewSet):
|
||||
if user and user.is_authenticated:
|
||||
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
|
||||
site = serializer.save(account=account)
|
||||
|
||||
|
||||
@@ -189,6 +189,83 @@ class CreditCostConfig(models.Model):
|
||||
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):
|
||||
"""
|
||||
Invoice for subscription or credit purchases
|
||||
|
||||
357
backend/igny8_core/business/billing/services/limit_service.py
Normal file
357
backend/igny8_core/business/billing/services/limit_service.py
Normal 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,
|
||||
}
|
||||
@@ -7,6 +7,7 @@ from .views import (
|
||||
PaymentViewSet,
|
||||
CreditPackageViewSet,
|
||||
AccountPaymentMethodViewSet,
|
||||
get_usage_summary,
|
||||
)
|
||||
from igny8_core.modules.billing.views import (
|
||||
CreditBalanceViewSet,
|
||||
@@ -29,4 +30,6 @@ router.register(r'payment-configs', BillingViewSet, basename='payment-configs')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
# User-facing usage summary endpoint for plan limits
|
||||
path('usage-summary/', get_usage_summary, name='usage-summary'),
|
||||
]
|
||||
|
||||
@@ -866,3 +866,44 @@ class AccountPaymentMethodViewSet(AccountModelViewSet):
|
||||
{'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results},
|
||||
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
|
||||
)
|
||||
|
||||
@@ -276,6 +276,55 @@ class Content(SoftDeletableModel, SiteSectorBaseModel):
|
||||
|
||||
def __str__(self):
|
||||
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):
|
||||
|
||||
@@ -30,12 +30,20 @@ class ContentGenerationService:
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
from igny8_core.business.billing.services.limit_service import LimitService, MonthlyLimitExceededError
|
||||
|
||||
# Get tasks
|
||||
tasks = Tasks.objects.filter(id__in=task_ids, account=account)
|
||||
|
||||
# Calculate estimated credits needed based on word count
|
||||
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
|
||||
try:
|
||||
self.credit_service.check_credits(account, 'content_generation', total_word_count)
|
||||
|
||||
@@ -25,6 +25,15 @@ app.conf.beat_schedule = {
|
||||
'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
|
||||
},
|
||||
# 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
|
||||
'check-scheduled-automations': {
|
||||
'task': 'automation.check_scheduled_automations',
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
168
backend/igny8_core/tasks/plan_limits.py
Normal file
168
backend/igny8_core/tasks/plan_limits.py
Normal 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(),
|
||||
}
|
||||
137
backend/igny8_core/utils/word_counter.py
Normal file
137
backend/igny8_core/utils/word_counter.py
Normal 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,
|
||||
}
|
||||
@@ -101,28 +101,6 @@ services:
|
||||
- "com.docker.compose.project=igny8-app"
|
||||
- "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:
|
||||
image: igny8-backend:latest
|
||||
container_name: igny8_celery_worker
|
||||
|
||||
677
docs/PLAN-LIMITS.md
Normal file
677
docs/PLAN-LIMITS.md
Normal 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**
|
||||
245
frontend/src/components/billing/UsageLimitsPanel.tsx
Normal file
245
frontend/src/components/billing/UsageLimitsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,17 @@ export interface PricingPlan {
|
||||
features: string[];
|
||||
buttonText: string;
|
||||
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 {
|
||||
@@ -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>
|
||||
</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>
|
||||
|
||||
<Button
|
||||
variant={plan.highlighted ? 'primary' : 'outline'}
|
||||
className="w-full"
|
||||
onClick={() => onPlanSelect?.(plan)}
|
||||
disabled={plan.disabled}
|
||||
>
|
||||
{plan.buttonText}
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
layer(base);
|
||||
|
||||
@import "./styles/tokens.css";
|
||||
@import "./styles/account-colors.css";
|
||||
@import "tailwindcss";
|
||||
|
||||
@keyframes slide-in-right {
|
||||
|
||||
@@ -502,6 +502,16 @@ export default function PlansAndBillingPage() {
|
||||
buttonText: plan.id === currentPlanId ? 'Current Plan' : 'Select Plan',
|
||||
highlighted: plan.is_featured || false,
|
||||
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}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 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 { TrendingUp, Activity, DollarSign } from 'lucide-react';
|
||||
import { TrendingUp, Activity, DollarSign, BarChart3 } from 'lucide-react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
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 BillingUsagePanel from '../../components/billing/BillingUsagePanel';
|
||||
import BillingBalancePanel from '../../components/billing/BillingBalancePanel';
|
||||
import UsageLimitsPanel from '../../components/billing/UsageLimitsPanel';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
|
||||
type TabType = 'credits' | 'api' | 'costs';
|
||||
type TabType = 'limits' | 'credits' | 'balance' | 'api' | 'costs';
|
||||
|
||||
export default function UsageAnalyticsPage() {
|
||||
const toast = useToast();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('credits');
|
||||
const [activeTab, setActiveTab] = useState<TabType>('limits');
|
||||
const [analytics, setAnalytics] = useState<UsageAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [period, setPeriod] = useState(30);
|
||||
@@ -51,6 +52,7 @@ export default function UsageAnalyticsPage() {
|
||||
}
|
||||
|
||||
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: '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" /> },
|
||||
@@ -64,7 +66,7 @@ export default function UsageAnalyticsPage() {
|
||||
<div className="mb-6">
|
||||
<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">
|
||||
Monitor credit usage, API calls, and cost breakdown
|
||||
Monitor plan limits, credit usage, API calls, and cost breakdown
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -112,6 +114,11 @@ export default function UsageAnalyticsPage() {
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="mt-6">
|
||||
{/* Plan Limits Tab */}
|
||||
{activeTab === 'limits' && (
|
||||
<UsageLimitsPanel />
|
||||
)}
|
||||
|
||||
{/* Credit Usage Tab */}
|
||||
{activeTab === 'credits' && (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -865,6 +865,48 @@ export interface Plan {
|
||||
features?: string[];
|
||||
limits?: Record<string, any>;
|
||||
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 {
|
||||
@@ -942,3 +984,11 @@ export async function cancelSubscription(subscriptionId: number): Promise<{ mess
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// USAGE SUMMARY (PLAN LIMITS)
|
||||
// ============================================================================
|
||||
|
||||
export async function getUsageSummary(): Promise<UsageSummary> {
|
||||
return fetchAPI('/v1/billing/usage-summary/');
|
||||
}
|
||||
|
||||
435
frontend/src/styles/account-colors.css
Normal file
435
frontend/src/styles/account-colors.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user